From c3cf5dbf7c3c68d1e111fc8d5122f7999a276c0c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 16:15:42 -0400 Subject: [PATCH 1/7] feat(chat): send photos from the composer and show them in the chat The photo UI on top of the transport and composer PRs, ported from code-ios-app#959. - Attach menu: + grows into a menu card drawn in non-focusable windows above the keyboard, so the keyboard stays up. Back closes the menu only. - Camera: the menu card grows into a camera card, 58% of the screen tall. The camera starts while the menu is open, holds 30 fps, and a shot is staged as a chip at the shutter while the file is still being written. - Picker: Photos opens the system picker sheet; picks become chips and the keyboard comes back up. - Bubbles: photo bubbles with a BlurHash placeholder, upload progress, retry, and "This photo can't be displayed" for one that won't open. A sent photo keeps drawing while its row swaps to the stored copy. - Viewer: tapping a photo opens an overlay that grows out of the bubble; drag, close or Back shrinks it back into the bubble. - Replies: a quoted photo shows its thumbnail in the reply strip and in the quote panel, and a restored reply draft keeps it. - Capture files left behind by a killed run are deleted when a chat opens. --- .../ui/navigation/AppScreenContent.kt | 2 + .../app/src/main/res/xml/file_paths.xml | 2 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 9 + .../core/src/main/res/values/strings.xml | 25 + .../app/messenger/ChatPhotoViewerScreen.kt | 41 + .../messenger/internal/ChatDraftBinding.kt | 4 + .../internal/ChatPhotoViewerViewModel.kt | 48 + .../app/messenger/internal/ChatViewModel.kt | 249 +++- .../internal/screens/MessengerScreen.kt | 31 +- .../screens/components/ChatBottomBar.kt | 281 ++++- .../screens/components/SendCashButton.kt | 21 +- .../internal/ChatComposerPhotosTest.kt | 94 ++ .../internal/ChatGroupAnalyticsTest.kt | 5 +- .../internal/ChatGroupCashLinkTest.kt | 5 +- .../internal/ChatMentionPickerTest.kt | 5 +- .../internal/ChatOpenTranscriptTest.kt | 5 +- .../internal/ChatSendFailureAnalyticsTest.kt | 5 +- .../app/messenger/internal/NoMediaUploads.kt | 11 + apps/flipcash/shared/chat-ui/build.gradle.kts | 8 + .../flipcash/shared/chat/models/ChatAction.kt | 3 + .../shared/chat/models/ChatListItem.kt | 24 + .../flipcash/shared/chat/models/ChatQuote.kt | 23 + .../flipcash/shared/chat/ui/ChatAnimations.kt | 4 + .../flipcash/shared/chat/ui/ChatQuotePanel.kt | 30 +- .../shared/chat/ui/ChatSummaryMapping.kt | 13 +- .../shared/chat/ui/ComposerReplyStrip.kt | 52 +- .../flipcash/shared/chat/ui/MessageBubble.kt | 29 +- .../shared/chat/ui/media/AttachSurface.kt | 1099 +++++++++++++++++ .../shared/chat/ui/media/ChatCameraPanel.kt | 546 ++++++++ .../chat/ui/media/ChatMediaBubbleSizing.kt | 35 + .../shared/chat/ui/media/ChatMediaViewer.kt | 354 ++++++ .../chat/ui/media/ChatMediaZoomState.kt | 133 ++ .../shared/chat/ui/media/ChatPhotoBubble.kt | 288 +++++ .../shared/chat/ui/media/ChatPhotoOverlay.kt | 222 ++++ .../shared/chat/ui/media/ChatPhotoPicker.kt | 46 + .../chat/ui/media/ChatPhotoProgressOverlay.kt | 189 +++ .../shared/chat/ui/media/ChatPhotoShare.kt | 49 + .../shared/chat/ui/media/ChatPhotoSources.kt | 94 ++ .../chat/ui/media/ChatPhotoTransition.kt | 128 ++ .../chat/ui/media/ComposerPhotoChips.kt | 255 ++++ .../chat/ui/media/MediaMessageBubble.kt | 195 +++ .../chat/ui/media/PhotoQuoteThumbnail.kt | 87 ++ .../shared/chat/ui/media/ReducedMotion.kt | 23 + .../shared/chat/ui/ChatSummaryPreviewTest.kt | 28 +- .../chat/ui/media/AttachCardGeometryTest.kt | 63 + .../shared/chat/ui/media/AttachRowsTest.kt | 45 + .../ui/media/ChatMediaBubbleSizingTest.kt | 56 + .../shared/chat/ui/media/ChatMediaZoomTest.kt | 52 + .../chat/ui/media/ChatPhotoMediaUiTest.kt | 222 ++++ .../chat/ui/media/ChatPhotoTransitionTest.kt | 119 ++ .../chat/ui/media/ComposerChipEdgeFadeTest.kt | 22 + .../chat/ui/media/MediaMessageBubbleTest.kt | 130 ++ .../src/test/resources/chat_media.json | 639 ++++++++++ 53 files changed, 6056 insertions(+), 92 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatPhotoViewerScreen.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatPhotoViewerViewModel.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatComposerPhotosTest.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/NoMediaUploads.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/AttachSurface.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatCameraPanel.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaBubbleSizing.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomState.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoBubble.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoPicker.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoShare.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSources.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransition.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ComposerPhotoChips.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/PhotoQuoteThumbnail.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ReducedMotion.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/AttachCardGeometryTest.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/AttachRowsTest.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaBubbleSizingTest.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomTest.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransitionTest.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ComposerChipEdgeFadeTest.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubbleTest.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/resources/chat_media.json diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 04e242cc5b..3cd29c7ad2 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -37,6 +37,7 @@ import com.flipcash.app.currency.RegionSelectionScreen import com.flipcash.app.deposit.DepositFlowScreen import com.flipcash.app.invite.InviteContactScreen import com.flipcash.app.messenger.ChatFlowScreen +import com.flipcash.app.messenger.ChatPhotoViewerScreen import com.flipcash.app.messenger.E2eeLearnMoreScreen import com.flipcash.app.messenger.MuteChatScreen import com.flipcash.app.messenger.ProfileScreen @@ -131,6 +132,7 @@ fun appEntryProvider( ReportFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } annotatedEntry { key -> MuteChatScreen(key.chatId, key.chatType) } + annotatedEntry { key -> ChatPhotoViewerScreen(key.chatId, key.messageId) } annotatedEntry { E2eeLearnMoreScreen(forGroup = false) } annotatedEntry { E2eeLearnMoreScreen(forGroup = true) } diff --git a/apps/flipcash/app/src/main/res/xml/file_paths.xml b/apps/flipcash/app/src/main/res/xml/file_paths.xml index df595ae07d..59628fd959 100644 --- a/apps/flipcash/app/src/main/res/xml/file_paths.xml +++ b/apps/flipcash/app/src/main/res/xml/file_paths.xml @@ -5,4 +5,6 @@ + + diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 7d7c349b48..c42bbe379e 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -460,6 +460,15 @@ sealed interface AppRoute : NavKey, Parcelable { com.getcode.navigation.Sheet, com.getcode.navigation.WrapContentSheet + /** + * The full-screen viewer for the photo in message [messageId] of [chatId]. Carries the + * message's address rather than the photo, which is not something a back stack can hold; the + * viewer reads the message back to learn how to fetch and open it. + */ + @Serializable + @Parcelize + data class PhotoViewer(val chatId: ChatId, val messageId: Long) : Messaging + /** The group counterpart of [E2eeDmInfo], opened from Group Info's footer. */ @Serializable @Parcelize diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 5ac14e95bc..6f4b64e593 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -974,6 +974,8 @@ You received %1$s You tipped %1$s Someone tipped you %1$s + Photo + This photo can’t be displayed You: %1$s %1$s: %2$s %1$s sent %2$s @@ -1367,4 +1369,27 @@ Share of max supply Your current appreciation Price curve for %1$s with %2$s purchased + + Close + Share + Share photo + Remove photo + Retry photo upload + Photo can\'t be sent + Photo upload progress + Cash + Camera + Photos + Attach + Back to attach menu + Take photo + More camera controls + Hide camera controls + Turn flash on + Turn flash off + Use front camera + Use back camera + Turn on Camera in Settings to send photos + Open Settings + Camera unavailable diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatPhotoViewerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatPhotoViewerScreen.kt new file mode 100644 index 0000000000..2d76f833ad --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatPhotoViewerScreen.kt @@ -0,0 +1,41 @@ +package com.flipcash.app.messenger + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.messenger.internal.ChatPhotoViewerViewModel +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ui.media.ChatMediaViewer +import com.flipcash.shared.chat.ui.media.rememberSharePhoto +import com.getcode.navigation.core.LocalCodeNavigator + +/** The full-screen photo, behind `AppRoute.Messaging.PhotoViewer`. Black, dismissed by back or a pull. */ +@Composable +fun ChatPhotoViewerScreen(chatId: ChatId, messageId: Long) { + val viewModel = hiltViewModel() + LaunchedEffect(chatId, messageId) { viewModel.load(chatId, messageId) } + val photo by viewModel.photo.collectAsStateWithLifecycle() + val navigator = LocalCodeNavigator.current + var fullImageLoaded by remember { mutableStateOf(false) } + val share = rememberSharePhoto(photo) + + Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { + ChatMediaViewer( + model = photo, + fullImageLoaded = fullImageLoaded, + onFullImageLoaded = { fullImageLoaded = true }, + onShare = share, + onDismiss = { navigator.pop() }, + ) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatDraftBinding.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatDraftBinding.kt index 39014577bd..48d7efe5a7 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatDraftBinding.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatDraftBinding.kt @@ -3,6 +3,7 @@ package com.flipcash.app.messenger.internal import com.flipcash.shared.chat.ChatDraftReply import com.flipcash.shared.chat.ChatDraftSnippet import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.media.ChatMediaText import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat @@ -22,6 +23,9 @@ internal fun ChatQuote.toDraftReply(): ChatDraftReply = ChatDraftReply( senderIdHex = senderIdHex, snippet = when (val snippet = snippet) { is ChatQuoteSnippet.Text -> ChatDraftSnippet.Text(snippet.body) + // A stored draft carries words only; the thumbnail is not persisted with it. + is ChatQuoteSnippet.Photo -> + ChatDraftSnippet.Text(ChatMediaText.preview(snippet.caption, "Photo")) is ChatQuoteSnippet.Cash -> ChatDraftSnippet.Cash( quarks = snippet.amount.quarks, currencyCode = snippet.amount.currencyCode.name, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatPhotoViewerViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatPhotoViewerViewModel.kt new file mode 100644 index 0000000000..4837752a0f --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatPhotoViewerViewModel.kt @@ -0,0 +1,48 @@ +package com.flipcash.app.messenger.internal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.media.ChatPhoto +import com.flipcash.shared.chat.ui.media.photoBody +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Reads the photo behind a viewer route. The route carries only the message's address, so this looks + * the message up and builds the Coil model the transcript's bubble loads from, which is what lets the + * viewer open on what the bubble already cached. + */ +@HiltViewModel +internal class ChatPhotoViewerViewModel @Inject constructor( + private val chatCoordinator: ChatCoordinator, +) : ViewModel() { + + private val _photo = MutableStateFlow(null) + val photo: StateFlow = _photo + + private var loaded: Pair? = null + + fun load(chatId: ChatId, messageId: Long) { + if (loaded == chatId to messageId) return + loaded = chatId to messageId + viewModelScope.launch { + val message = chatCoordinator.getMessage(chatId, messageId) ?: return@launch + val rendition = message.content.firstNotNullOfOrNull { it.photoBody() } + ?.items?.firstOrNull() + ?.let { item -> item.rendition(com.flipcash.services.models.chat.MediaItemRendition.Role.ORIGINAL) ?: item.renditions.firstOrNull() } + ?: return@launch + _photo.value = ChatPhoto( + chatId = chatId, + rendition = rendition, + senderId = message.senderId, + sealed = message.encryption != null, + redacted = message.redacted, + ) + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index b59eeaa9c4..d03d7c0785 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -84,6 +84,10 @@ import com.flipcash.shared.chat.ChatDraftSnapshot import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.ChatHydration import com.flipcash.shared.chat.ChatMembership +import com.flipcash.shared.chat.media.ChatMediaUploadState +import com.flipcash.shared.chat.media.ChatMediaUploads +import com.flipcash.shared.chat.media.MediaSendProgress +import com.flipcash.shared.chat.ui.media.MAX_STAGED_PHOTOS import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.shared.chat.GroupAccess import com.flipcash.shared.chat.SpeakerBlock @@ -101,6 +105,9 @@ import com.flipcash.shared.chat.groupAccess import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.shared.chat.models.ChatQuoteSnippet +import com.flipcash.shared.chat.models.PhotoMessageContext +import com.flipcash.shared.chat.ui.media.photoBody +import com.flipcash.shared.chat.ui.media.photoContext import com.flipcash.shared.chat.models.LinkCard import com.flipcash.shared.chat.models.LinkCardResolution import com.flipcash.shared.chat.models.ReceiptStatus @@ -150,10 +157,14 @@ import kotlin.math.min import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay +import android.graphics.Bitmap +import android.net.Uri +import java.io.File import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -198,6 +209,7 @@ data class TypingConstraints( @HiltViewModel internal class ChatViewModel @Inject constructor( private val chatCoordinator: ChatCoordinator, + private val mediaUploads: ChatMediaUploads, private val e2eePolicy: E2eePolicy, private val contactCoordinator: ContactCoordinator, private val contactPaymentDelegate: ContactPaymentDelegate, @@ -229,6 +241,13 @@ internal class ChatViewModel @Inject constructor( defaultDispatcher = dispatchers.Default, ) { + /** + * A photo in the composer: its id in [ChatMediaUploads] and where it came from, for the + * thumbnail. A camera shot staged at the shutter carries the frame taken then as [preview], + * since [source] is not written yet. + */ + data class StagedPhoto(val id: String, val source: Uri, val preview: Bitmap? = null) + sealed interface ResolveState { data object Pending : ResolveState // The counterparty resolved to an on-chain address, so the send can proceed. The address @@ -321,6 +340,10 @@ internal class ChatViewModel @Inject constructor( * emits, so a reducer that cleared it would empty it before the handler could read it. */ val replyingTo: ChatQuote? = null, + /** Photos staged in the composer, in the order they were added. Hidden, not dropped, while editing. */ + val stagedPhotos: List = emptyList(), + /** Where each staged photo's upload is; a chip with no entry yet is still being picked up. */ + val uploadStates: Map = emptyMap(), /** * Members matching the `@` word at the composer's cursor, for the mention picker. Empty * whenever the picker is closed: outside a group, with no `@` word at the cursor, or when @@ -501,6 +524,22 @@ internal class ChatViewModel @Inject constructor( val replacesComposer: Boolean get() = joinProgress.success || isOutsideGroup + /** The staged photos the composer shows: none while editing, which is text only. */ + val composerPhotos: List + get() = if (editing != null) emptyList() else stagedPhotos + + /** Whether the composer takes photos: the chat is known and no edit is in progress. */ + val acceptsMedia: Boolean + get() = chatId != null && editing == null + + /** A photo that failed to upload blocks sending until it is retried or removed. */ + val hasFailedPhoto: Boolean + get() = composerPhotos.any { uploadStates[it.id] is ChatMediaUploadState.Failed } + + /** Whether the send control does anything: text or a photo to send, and no failed photo. */ + fun canSendComposer(hasText: Boolean): Boolean = + (hasText || composerPhotos.isNotEmpty()) && !hasFailedPhoto + /** * What a tap on a cash card in this transcript does. * @@ -631,6 +670,28 @@ internal class ChatViewModel @Inject constructor( data object ResolveFailed : Event data object SendMessage : Event + + /** Stages [uris] as photos, up to [MAX_STAGED_PHOTOS] in all. [captured] ones are camera files to delete once safe. */ + data class StagePhotos(val uris: List, val captured: Boolean = false) : Event + data class PhotoStaged(val photo: StagedPhoto) : Event + + /** + * The shutter was pressed: stage the shot now, as [preview], so its chip is there for the + * camera to land on; reading [uri] waits for [CaptureFinished]. + */ + data class CaptureStarted(val uri: Uri, val preview: Bitmap?) : Event + + /** The shot for [uri] was written, or, when not [saved], failed and leaves the composer. */ + data class CaptureFinished(val uri: Uri, val saved: Boolean) : Event + data class RemovePhoto(val id: String) : Event + data class RetryPhoto(val id: String) : Event + data class UploadStatesChanged(val states: Map) : Event + + /** The staged photos went into a send; they leave the composer without being removed. */ + data class PhotosSent(val ids: Set) : Event + + /** A send of [photos] did not go; they return to the composer. */ + data class PhotosRestored(val photos: List) : Event data class RetryMessage(val pendingId: String?, val content: MessageContent) : Event data object NavigateToAmountEntry : Event @@ -818,6 +879,16 @@ internal class ChatViewModel @Inject constructor( /** Whether the speaker rules leave reactions open; wider than [viewerCanSpeak], see `speakerBlocksReactions`. */ private val viewerCanReact = stateFlow.map { !it.speakerBlocksReactions }.distinctUntilChanged() + /** Whether the viewer is reading a group they have not joined, which shows its photos as BlurHash only. */ + private val viewerPreviewing = stateFlow.map { it.isOutsideGroup }.distinctUntilChanged() + + private data class ViewerGates( + val canPost: Boolean, + val canSpeak: Boolean, + val canReact: Boolean, + val previewing: Boolean, + ) + /** * Live reaction overrides for the open chat β€” see [ReactionOperations.observeChatReactions]. * A message missing here falls back to `MessageReactions.from(message.reactions)` in @@ -898,6 +969,11 @@ internal class ChatViewModel @Inject constructor( */ private val claimReplyTargets = ClaimReplyTargets() + /** Progress of the photos this device is sending, for the transcript's overlays. */ + val mediaSendProgress: StateFlow> = + chatCoordinator.observeMediaSendProgress() + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap()) + /** * The transcript's rows before separators. Cached so a change to the unread boundary or the * separator config re-runs only [messages]' separator pass, not the per-message lookups here. @@ -909,8 +985,8 @@ internal class ChatViewModel @Inject constructor( pendingMutations, messagePolicy, senderProfiles, - combine(viewerCanPost, viewerCanSpeak, viewerCanReact, ::Triple), - ) { pagingData, mutations, policy, profiles, (canPost, canSpeak, canReactToMessages) -> + combine(viewerCanPost, viewerCanSpeak, viewerCanReact, viewerPreviewing, ::ViewerGates), + ) { pagingData, mutations, policy, profiles, (canPost, canSpeak, canReactToMessages, previewing) -> pagingData.flatMap { stored -> val message = stored.applying(mutations[stored.messageId]) message.content.flatMapIndexed { index, content -> @@ -1028,6 +1104,8 @@ internal class ChatViewModel @Inject constructor( reactionPills = storedReactions.pills, selfReactions = storedReactions.selfReactions, canReact = canReact(message, canSpeak = canPost && canReactToMessages), + photo = stateFlow.value.chatId?.takeIf { content.photoBody() != null } + ?.let { chatId -> message.photoContext(chatId, previewing) }, undecryptableHint = undecryptableHint( encryption = message.encryption, isFromSelf = message.isFromSelf, @@ -1121,6 +1199,15 @@ internal class ChatViewModel @Inject constructor( } } + private fun MessageContent.Media.toPhotoSnippet(message: ChatMessage) = ChatQuoteSnippet.Photo( + caption = caption?.text?.takeIf { it.isNotBlank() }, + rendition = items.firstOrNull()?.renditions?.firstOrNull(), + sealed = message.encryption != null, + redacted = message.redacted, + chatId = stateFlow.value.chatId, + senderId = message.senderId, + ) + private suspend fun ChatMessage.toQuote(): ChatQuote { val body = content.firstOrNull() val palette = senderId?.let { generateComplementaryColorPalette(it) } @@ -1138,6 +1225,8 @@ internal class ChatViewModel @Inject constructor( is MessageContent.Text -> ChatQuoteSnippet.Text(body.text) + is MessageContent.Media -> body.toPhotoSnippet(this) + // Named the way the chat list previews it, so a citation and its source agree. is MessageContent.Widget -> ChatQuoteSnippet.Text( when (body.widget) { @@ -1148,10 +1237,13 @@ internal class ChatViewModel @Inject constructor( ) // A reply to a reply cites the inner body, not the nested citation. - is MessageContent.Reply -> ChatQuoteSnippet.Text( - body.content.filterIsInstance() - .firstOrNull()?.text.orEmpty() - ) + is MessageContent.Reply -> when (val inner = body.content.firstOrNull()) { + is MessageContent.Media -> inner.toPhotoSnippet(this) + else -> ChatQuoteSnippet.Text( + body.content.filterIsInstance() + .firstOrNull()?.text.orEmpty() + ) + } else -> ChatQuoteSnippet.Text("") }, @@ -1281,6 +1373,13 @@ internal class ChatViewModel @Inject constructor( override fun onStop(owner: LifecycleOwner) = flushDraft() } + // Declared above init, whose photo handlers read them as soon as they start. + /** Camera files by chip id, deleted once the photo is encoded into the app's own storage. */ + private val captureFiles = HashMap() + + /** Shots staged at the shutter and not yet written, by file: the chip id and its readiness. */ + private val pendingCaptures = HashMap>>() + init { // Essential β€” needed immediately for chat display initChatHandlers() @@ -1295,6 +1394,7 @@ internal class ChatViewModel @Inject constructor( initTokenAndExchangeObservers() initTypingHandlers() initSendHandlers() + initPhotoHandlers() initMentionPicker() initMessageActionHandlers() } @@ -2341,13 +2441,89 @@ internal class ChatViewModel @Inject constructor( .launchIn(viewModelScope) } + private fun initPhotoHandlers() { + eventFlow.filterIsInstance() + .onEach { event -> + val chatId = stateFlow.value.chatId ?: return@onEach + if (stateFlow.value.editing != null) return@onEach + val room = MAX_STAGED_PHOTOS - stateFlow.value.stagedPhotos.size + event.uris.take(room.coerceAtLeast(0)).forEach { uri -> + val id = mediaUploads.stage(chatId, uri) + if (event.captured) uri.path?.let { captureFiles[id] = File(it) } + dispatchEvent(Event.PhotoStaged(StagedPhoto(id, uri))) + } + // Anything over the limit is not staged, and a camera file for it has no use. + if (event.captured) event.uris.drop(room.coerceAtLeast(0)).forEach { it.path?.let { path -> File(path).delete() } } + } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { event -> + val chatId = stateFlow.value.chatId + val ready = CompletableDeferred() + val canStage = chatId != null && stateFlow.value.editing == null && + stateFlow.value.stagedPhotos.size < MAX_STAGED_PHOTOS + if (!canStage) { + pendingCaptures[event.uri] = null to ready + return@onEach + } + val id = mediaUploads.stage(chatId!!, event.uri, ready) + event.uri.path?.let { captureFiles[id] = File(it) } + pendingCaptures[event.uri] = id to ready + dispatchEvent(Event.PhotoStaged(StagedPhoto(id, event.uri, event.preview))) + } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { event -> + val (id, ready) = pendingCaptures.remove(event.uri) ?: return@onEach + ready.complete(event.saved) + val staged = id != null && stateFlow.value.stagedPhotos.any { it.id == id } + when { + // Not staged (no room), or removed while it was being written: nothing uses it. + !staged -> event.uri.path?.let { File(it).delete() } + !event.saved -> dispatchEvent(Event.RemovePhoto(id!!)) + } + } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { event -> + mediaUploads.remove(event.id) + captureFiles.remove(event.id)?.delete() + } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { event -> mediaUploads.retry(event.id) } + .launchIn(viewModelScope) + + mediaUploads.allStates + .onEach { states -> + dispatchEvent(Event.UploadStatesChanged(states)) + // Past encoding the app holds its own copy, so the camera's can go. + captureFiles.entries.removeAll { (id, file) -> + val encoded = when (states[id]) { + is ChatMediaUploadState.Uploading, + is ChatMediaUploadState.Processing, + is ChatMediaUploadState.Uploaded -> true + else -> false + } + if (encoded) file.delete() + encoded + } + } + .launchIn(viewModelScope) + } + private fun initSendHandlers() { // Send text message eventFlow.filterIsInstance() .onEach { val textToSend = stateFlow.value.chatInputState.text.toString() val chatId = stateFlow.value.chatId ?: return@onEach - if (textToSend.isBlank()) return@onEach + val photos = stateFlow.value.composerPhotos + if (!stateFlow.value.canSendComposer(hasText = textToSend.isNotBlank())) return@onEach val chatType = stateFlow.value.chatType // Read here, not in the reducer: the reply strip comes down with the draft, and // both are the composer emptying itself once the message is on its way. @@ -2355,13 +2531,22 @@ internal class ChatViewModel @Inject constructor( stateFlow.value.chatInputState.setTextAndPlaceCursorAtEnd("") if (replyToMessageId != null) dispatchEvent(Event.CancelReply) + // The chips leave the composer now; the send owns them from here, and they come + // back below if it is refused. + if (photos.isNotEmpty()) dispatchEvent(Event.PhotosSent(photos.map { it.id }.toSet())) // Now, not on delivery: the text is in the pending message from here on, and a // send that fails leaves a "Not sent" bubble with a retry, which is the // transcript's own record of it. Restoring a draft as well would duplicate it. chatDraftStore.saveInBackground(chatId, ChatDraftSnapshot.Empty) viewModelScope.launch { - chatCoordinator.sendMessage(chatId, textToSend, replyToMessageId) + val sent = if (photos.isEmpty()) { + chatCoordinator.sendMessage(chatId, textToSend, replyToMessageId) + } else { + chatCoordinator.sendMedia(chatId, photos.map { it.id }, textToSend.trim(), replyToMessageId) + .onFailure { dispatchEvent(Event.PhotosRestored(photos)) } + } + sent .onSuccess { trace("message sent successfully") analytics.track(ChatEvents.sentMessage(chatType.analytics, null)) @@ -2389,9 +2574,15 @@ internal class ChatViewModel @Inject constructor( text = resources.getString(R.string.action_retry), ) { viewModelScope.launch { - chatCoordinator.retryMessage(chatId, pendingId, listOf(content)) - .onSuccess { trace("retry message sent successfully") } - .onFailure { trace("retry message failed - ${it.localizedMessage}") } + if (content.photoBody() != null) { + chatCoordinator.retryMedia(chatId, pendingId) + .onSuccess { trace("retry photo sent successfully") } + .onFailure { trace("retry photo failed - ${it.localizedMessage}") } + } else { + chatCoordinator.retryMessage(chatId, pendingId, listOf(content)) + .onSuccess { trace("retry message sent successfully") } + .onFailure { trace("retry message failed - ${it.localizedMessage}") } + } } }, ), @@ -2642,7 +2833,14 @@ internal class ChatViewModel @Inject constructor( withContext(Dispatchers.Main.immediate) { stateFlow.value.chatInputState.setTextAndPlaceCursorAtEnd(draft.text) } - draft.replyTarget?.let { dispatchEvent(Event.ReplyToMessage(it.toChatQuote())) } + val reply = draft.replyTarget ?: return + // The stored copy keeps a photo's words only. When the cited message is already on this + // device its quote is rebuilt from it, so the strip gets the thumbnail back. + val quote = runCatching { chatCoordinator.getMessage(chatId, reply.messageId) }.getOrNull() + ?.takeIf { it.content.firstOrNull() is MessageContent.Media } + ?.toQuote() + ?: reply.toChatQuote() + dispatchEvent(Event.ReplyToMessage(quote)) } override fun onCleared() { @@ -2653,6 +2851,12 @@ internal class ChatViewModel @Inject constructor( // unconditional clear would silence the chat the user just opened. chatCoordinator.clearActiveChat(stateFlow.value.chatId) linkCardResolver.dispose() + // Photos staged but never sent are not worth keeping upload state for. Sent ones have left + // this list already and belong to the send. + stateFlow.value.stagedPhotos.forEach { + mediaUploads.remove(it.id) + captureFiles.remove(it.id)?.delete() + } } private fun checkBalanceLimit(amount: Fiat): Boolean { @@ -2908,6 +3112,27 @@ internal class ChatViewModel @Inject constructor( if (clears) state.copy(unreadBoundary = UnreadBoundary.None) else state } is Event.RetryMessage -> { state -> state } + is Event.StagePhotos -> { state -> state } + is Event.CaptureStarted -> { state -> state } + is Event.CaptureFinished -> { state -> state } + is Event.RetryPhoto -> { state -> state } + is Event.PhotoStaged -> { state -> + if (state.stagedPhotos.size >= MAX_STAGED_PHOTOS) state + else state.copy(stagedPhotos = state.stagedPhotos + event.photo) + } + is Event.RemovePhoto -> { state -> + state.copy( + stagedPhotos = state.stagedPhotos.filterNot { it.id == event.id }, + uploadStates = state.uploadStates - event.id, + ) + } + is Event.UploadStatesChanged -> { state -> state.copy(uploadStates = event.states) } + is Event.PhotosSent -> { state -> + state.copy(stagedPhotos = state.stagedPhotos.filterNot { it.id in event.ids }) + } + is Event.PhotosRestored -> { state -> + state.copy(stagedPhotos = (event.photos + state.stagedPhotos).take(MAX_STAGED_PHOTOS)) + } Event.NavigateToAmountEntry -> { state -> state.copy(sendProgress = LoadingSuccessState()) } Event.NavigateToInitPayment -> { state -> state.copy(sendProgress = LoadingSuccessState()) } is Event.PresentDepositOptions -> { state -> state } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 924bd31056..86ee87c933 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -9,6 +9,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import com.flipcash.shared.chat.ui.media.ChatPhotoOverlay +import com.flipcash.shared.chat.ui.media.ChatPhotoSources +import com.flipcash.shared.chat.ui.media.LocalChatMediaProgress +import com.flipcash.shared.chat.ui.media.LocalChatPhotoSources import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -63,6 +67,8 @@ import kotlinx.coroutines.flow.filterIsInstance internal fun MessengerScreen(viewModel: ChatViewModel) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val messages = viewModel.messages.collectAsLazyPagingItems() + val mediaProgress = viewModel.mediaSendProgress.collectAsStateWithLifecycle() + val mediaProgressOf = remember(mediaProgress) { { mediaProgress.value } } val otherReadPointer by viewModel.otherReadPointer.collectAsStateWithLifecycle(null) val navigator = LocalCodeNavigator.current val uriHandler = LocalUriHandler.current @@ -82,6 +88,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { // height, which the selection and editing modes change. var barHeight by remember { mutableStateOf(0.dp) } val keyboard = rememberKeyboardController() + // The photo bubbles on screen, and the photo opened over them. The overlay sits above the whole + // screen rather than being a route, so it can grow out of and shrink back into its bubble. + val photoSources = remember { ChatPhotoSources() } + var openPhotoId by remember { mutableStateOf(null) } // Here rather than in the action handler because the lookup comes first: the tap only knows a // handle, and where it leads is the view model's to decide once the handle has an answer. @@ -114,6 +124,11 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { viewModel.dispatchEvent(ChatViewModel.Event.RefreshContact) } + is ChatAction.OpenPhoto -> { + // After the keyboard is down, so the bubble the photo grows from has settled. + keyboard.hideIfVisible { openPhotoId = action.bubble.messageId } + } + is ChatAction.RetryMessage -> { keyboard.hideIfVisible { viewModel.dispatchEvent( @@ -323,6 +338,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { viewModel.dispatchEvent(ChatViewModel.Event.ClearMessageSelection) } + Box(modifier = Modifier.fillMaxSize()) { CodeScaffold( // The input bar rides the keyboard; the message list is inset by it either way. modifier = Modifier.imePadding(), @@ -386,7 +402,11 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { modifier = Modifier.fillMaxSize(), enabled = state.obscuresTranscript, ) { - CompositionLocalProvider(LocalUriHandler provides transcriptUriHandler) { + CompositionLocalProvider( + LocalUriHandler provides transcriptUriHandler, + LocalChatMediaProgress provides mediaProgressOf, + LocalChatPhotoSources provides photoSources, + ) { MessageList( modifier = Modifier .fillMaxSize() @@ -407,4 +427,13 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } } + + openPhotoId?.let { id -> + ChatPhotoOverlay( + messageId = id, + sources = photoSources, + onClosed = { openPhotoId = null }, + ) + } + } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index 9f5eab1c58..d07c344fe5 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -1,9 +1,32 @@ package com.flipcash.app.messenger.internal.screens.components +import android.net.Uri +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.activity.compose.BackHandler +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import com.flipcash.core.R as CoreR +import com.flipcash.shared.chat.media.ChatMediaUploadState +import com.flipcash.shared.chat.ui.media.AttachPhase +import com.flipcash.shared.chat.ui.media.AttachSurface +import com.flipcash.shared.chat.ui.media.AttachRow +import com.flipcash.shared.chat.ui.media.ComposerPhotoChip +import com.flipcash.shared.chat.ui.media.ComposerPhotoChipState +import com.flipcash.shared.chat.ui.media.ComposerPhotoChips +import com.flipcash.shared.chat.ui.media.MAX_STAGED_PHOTOS +import com.flipcash.shared.chat.ui.media.attachRows +import com.flipcash.shared.chat.ui.media.rememberChatPhotoPicker +import com.flipcash.shared.chat.ui.media.deleteLeftoverCaptures +import androidx.compose.ui.platform.LocalContext import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -40,10 +63,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.foundation.shape.CircleShape +import com.getcode.ui.components.chat.ChatInputDefaults +import androidx.compose.foundation.layout.size import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -51,6 +80,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp +import androidx.compose.ui.util.lerp +import com.flipcash.shared.chat.ui.ChatAnimations import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.balanceRequirement @@ -147,12 +178,59 @@ internal fun UserControlBottomBar( var mentionListHeight by remember { mutableStateOf(0.dp) } val material = HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background) + // The attach menu and the camera are one surface over the keyboard, which stays up behind it. + var attachPhase by remember { mutableStateOf(AttachPhase.Collapsed) } + // The camera file staged at the shutter and not written yet, to drop its chip if the write fails. + var pendingCapture by remember { mutableStateOf(null) } + var plusCoordinates by remember { mutableStateOf(null) } + // While the attach surface is out it draws "+" itself; this one hides so the two never stack. + var plusCovered by remember { mutableStateOf(false) } + // A capture's card leaves the + for the chip, so the + is back as the landing starts, popping in + // on the swap spring. A closing menu hands the + back as it starts to close: this one is back at + // full strength under the shrinking surface, which stops drawing its own, so the two never stack. + val plusShown = !plusCovered || attachPhase == AttachPhase.Landing + val plusPresence = remember { Animatable(1f) } + LaunchedEffect(plusShown) { + when { + !plusShown -> plusPresence.snapTo(0f) + attachPhase == AttachPhase.Landing -> plusPresence.animateTo(1f, ChatAnimations.swap) + else -> plusPresence.snapTo(1f) + } + } + // The newest staged chip, which a capture shrinks onto. + var newestChipCoordinates by remember { mutableStateOf(null) } + + // Picked photos are staged to go with a caption, so the composer takes focus once the picker + // sheet has handed the window back, as it does after a capture. + var focusAfterPick by remember { mutableStateOf(false) } + val pickPhotos = rememberChatPhotoPicker( + remaining = MAX_STAGED_PHOTOS - state.stagedPhotos.size, + onPicked = { uris -> + dispatch(ChatViewModel.Event.StagePhotos(uris)) + if (uris.isNotEmpty()) focusAfterPick = true + }, + ) + + val context = LocalContext.current + LaunchedEffect(Unit) { deleteLeftoverCaptures(context) } + LaunchedEffect(keyboard.visible) { if (!keyboard.visible) { + // Back with the keyboard up goes to the keyboard, not the menu: closing the keyboard + // closes the menu with it rather than leaving it floating over the bar. The camera card + // stays: asking for the camera permission takes the keyboard down for a moment. + if (attachPhase == AttachPhase.Menu) attachPhase = AttachPhase.Collapsed dispatch(ChatViewModel.Event.OnStopMessageInput) } } + // Edit mode is text only, and a send ends the staging: the panel and menu have nothing to do. + LaunchedEffect(state.acceptsMedia) { + if (!state.acceptsMedia) { + attachPhase = AttachPhase.Collapsed + } + } + // The bar is measured against the whole screen (the scaffold overlays it), so this is the // height the mention list's row cap weighs the transcript's share against. BoxWithConstraints { @@ -172,9 +250,9 @@ internal fun UserControlBottomBar( // doesn't move anything, since nothing here animates on its own. val keyboardOpen = keyboardOpenFraction() val hasComposer = state.typingConstraints.enabled - val compactInset = CodeTheme.dimens.grid.x6.coerceAtLeast(CodeTheme.dimens.inset) + val compactInset = CodeTheme.dimens.staticGrid.x6.coerceAtLeast(CodeTheme.dimens.inset) val sideInset = if (hasComposer) lerp(compactInset, CodeTheme.dimens.inset, keyboardOpen) else CodeTheme.dimens.inset - val restingDrop = if (hasComposer) lerp(CodeTheme.dimens.grid.x2, 0.dp, keyboardOpen) else 0.dp + val restingDrop = if (hasComposer) lerp(CodeTheme.dimens.staticGrid.x2, 0.dp, keyboardOpen) else 0.dp // The part of the navigation bar's inset the keyboard isn't covering, so the bar only sinks // into it once the keyboard has cleared it. val restingRoom = WindowInsets.navigationBars.exclude(WindowInsets.ime) @@ -209,7 +287,7 @@ internal fun UserControlBottomBar( placeable.place(0, 0) } } - .padding(vertical = CodeTheme.dimens.grid.x3) + .padding(vertical = CodeTheme.dimens.staticGrid.x3) .navigationBarsPadding() // typingConstraints.enabled starts false and only resolves a frame or two after // open, once Room confirms whether the chat has a cash message. Rendering the @@ -265,50 +343,173 @@ internal fun UserControlBottomBar( dispatch = dispatch, mentionListModifier = Modifier.measured { mentionListHeight = it.height }, ) + // Editing swaps the leading control rather than adding a banner above the bar: + // send-cash is not reachable mid-edit anyway, and cancel is what the slot is + // for while the edit is open. + val outsideControl: @Composable () -> Unit = { + Row { + if (state.editing != null) { + CancelEditButton( + onClick = { dispatch(ChatViewModel.Event.CancelEdit) }, + ) + } else { + SendCashButton( + state = state, + hazeState = hazeState, + hazeMaterial = material, + onClick = { + keyboard.hideIfVisible { + dispatch(ChatViewModel.Event.OnSendCash) + } + } + ) + } + } + } + // Without a composer the send-cash button is the whole bar. + if (!canType) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = sideInset), + ) { outsideControl() } + } Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = sideInset), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - verticalAlignment = Alignment.Bottom, ) { - // Editing swaps the leading control rather than adding a banner above the bar: - // send-cash is not reachable mid-edit anyway, and cancel is what the slot is - // for while the edit is open. - if (state.editing != null) { - CancelEditButton( - onClick = { dispatch(ChatViewModel.Event.CancelEdit) }, - ) - } else { - SendCashButton( - state = state, - hazeState = hazeState, - hazeMaterial = material, - onClick = { - keyboard.hideIfVisible { - dispatch(ChatViewModel.Event.OnSendCash) - } - } - ) - } - if (canType) { + val chips = remember(state.composerPhotos, state.uploadStates) { + state.composerPhotos.map { photo -> + ComposerPhotoChip( + id = photo.id, + model = photo.preview ?: photo.source, + state = when (val upload = state.uploadStates[photo.id]) { + is ChatMediaUploadState.Failed -> ComposerPhotoChipState.Failed(upload.retryable) + is ChatMediaUploadState.Uploading -> ComposerPhotoChipState.Uploading + is ChatMediaUploadState.Processing, + is ChatMediaUploadState.Uploaded -> ComposerPhotoChipState.Uploaded + is ChatMediaUploadState.Preparing, null -> ComposerPhotoChipState.Preparing + }, + ) + } + } ChatInput( modifier = Modifier .testTag("chat_message_input") - .weight(1f) - .border( - CodeTheme.dimens.border, - CodeTheme.colors.divider, - CodeTheme.shapes.medium, - ) .hazeBlur(HazeInput.Sources(hazeState), material), + outside = outsideControl, + outsideCollapses = state.editing == null, focusRequester = focusRequester, hint = stringResource( if (state.replyingTo != null) R.string.hint_chatReply else R.string.hint_chatMessage ), state = state.chatInputState, + hasAttachments = chips.isNotEmpty(), + sendEnabled = !state.hasFailedPhoto, + leading = if (state.editing == null && state.chatId != null) { + { + Box { + Box( + modifier = Modifier + .testTag("chat_attach_button") + .size(ChatInputDefaults.AccessorySize) + // Measured outside the hide/pop scale below, so the + // surface always collapses onto the full-size "+". + .onGloballyPositioned { plusCoordinates = it } + .graphicsLayer { + val p = plusPresence.value + alpha = p.coerceIn(0f, 1f) + val scale = lerp(ChatAnimations.reactionEnterScale, 1f, p) + scaleX = scale + scaleY = scale + } + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.1f), CircleShape) + .clickable { + attachPhase = if (attachPhase == AttachPhase.Menu) { + AttachPhase.Collapsed + } else { + AttachPhase.Menu + } + }, + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), + imageVector = Icons.Outlined.Add, + contentDescription = stringResource(CoreR.string.description_chatAttachMenu), + tint = CodeTheme.colors.textMain, + ) + } + AttachSurface( + phase = attachPhase, + plus = { plusCoordinates }, + landing = { newestChipCoordinates }, + // The $ button beside the field stays the way to send cash. + rows = attachRows( + cashOffered = false, + acceptsMedia = state.acceptsMedia, + stagedCount = state.stagedPhotos.size, + ), + onRowClick = { row -> + when (row) { + AttachRow.Camera -> attachPhase = AttachPhase.Camera + AttachRow.Photos -> { + attachPhase = AttachPhase.Collapsed + pickPhotos() + } + AttachRow.Cash -> attachPhase = AttachPhase.Collapsed + } + }, + // Back steps out: the camera to the menu, the menu to the field. + onBack = { + attachPhase = if (attachPhase == AttachPhase.Camera) { + AttachPhase.Menu + } else { + AttachPhase.Collapsed + } + }, + onDismissRequest = { attachPhase = AttachPhase.Collapsed }, + // The shot is staged at the shutter, so its chip is there to + // land on while the file is still being written. + onShutter = { preview, uri -> + pendingCapture = uri + dispatch(ChatViewModel.Event.CaptureStarted(uri, preview?.asAndroidBitmap())) + attachPhase = AttachPhase.Landing + focusRequester.requestFocus() + keyboard.show() + }, + onCaptured = { uri -> + if (pendingCapture == uri) pendingCapture = null + dispatch(ChatViewModel.Event.CaptureFinished(uri, saved = true)) + }, + onCameraError = { + pendingCapture?.let { dispatch(ChatViewModel.Event.CaptureFinished(it, saved = false)) } + pendingCapture = null + attachPhase = AttachPhase.Collapsed + }, + onCoverChange = { plusCovered = it }, + onGone = { + if (attachPhase == AttachPhase.Landing) attachPhase = AttachPhase.Collapsed + }, + ) + } + } + } else null, + header = if (chips.isNotEmpty()) { + { + ComposerPhotoChips( + chips = chips, + onRemove = { dispatch(ChatViewModel.Event.RemovePhoto(it)) }, + onRetry = { dispatch(ChatViewModel.Event.RetryPhoto(it)) }, + modifier = Modifier, + newestChipModifier = Modifier.onGloballyPositioned { newestChipCoordinates = it }, + ) + } + } else null, // One read of the edit state decides both the glyph and what the tap // does, so the composer cannot show a checkmark and send a new message. submit = if (state.editing != null) { @@ -347,6 +548,14 @@ internal fun UserControlBottomBar( // post-tip open), focus the input and show the keyboard. Co-located with // ChatInput so focusRequester is guaranteed attached; consumes the request so // it fires once and a later manual dismiss doesn't re-open it. + LaunchedEffect(focusAfterPick) { + if (focusAfterPick) { + focusRequester.requestFocus() + keyboard.show() + focusAfterPick = false + } + } + LaunchedEffect(state.messageInputRequested) { if (state.messageInputRequested) { focusRequester.requestFocus() @@ -366,13 +575,13 @@ internal fun UserControlBottomBar( /** Leaves edit mode. Sized to the send-cash button it stands in for so the bar doesn't reflow. */ @Composable private fun CancelEditButton(onClick: () -> Unit) { - val shape = CodeTheme.shapes.medium + val shape = CircleShape Box( modifier = Modifier - .defaultMinSize(minWidth = 54.dp, minHeight = 54.dp) + .size(ChatInputDefaults.OutsideSize) .clip(shape) - .background(Color.White.copy(alpha = 0.1f), shape) - .border(CodeTheme.dimens.border, CodeTheme.colors.divider, shape) + .background(ChatInputDefaults.ContainerColor, shape) + .border(CodeTheme.dimens.border, ChatInputDefaults.RimBrush, shape) .clickable(onClick = onClick) .testTag("chat_cancel_edit"), contentAlignment = Alignment.Center, @@ -393,7 +602,7 @@ private fun DeactivatedChatBottomBar() { .fillMaxWidth() .navigationBarsPadding() .padding(horizontal = CodeTheme.dimens.inset) - .padding(vertical = CodeTheme.dimens.grid.x3), + .padding(vertical = CodeTheme.dimens.staticGrid.x3), contentAlignment = Alignment.Center, ) { Text( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt index 69a0408d84..744984ba01 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt @@ -17,6 +17,8 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.shape.CircleShape +import com.getcode.ui.components.chat.ChatInputDefaults import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.padding @@ -82,7 +84,7 @@ internal fun RowScope.SendCashButton( val colorSpec: AnimationSpec = if (hasSettled) tween(durationMillis = 350) else snap() val backgroundColor by animateColorAsState( - targetValue = if (isTyping) Color.Transparent else Color.White, + targetValue = if (isTyping) ChatInputDefaults.ContainerColor else Color.White, animationSpec = colorSpec, label = "send button background", ) @@ -92,7 +94,7 @@ internal fun RowScope.SendCashButton( label = "send button content", ) val borderColor by animateColorAsState( - targetValue = if (isTyping) CodeTheme.colors.divider else Color.Transparent, + targetValue = if (isTyping) Color.White.copy(alpha = 0.1f) else Color.Transparent, animationSpec = colorSpec, label = "send button border", ) @@ -121,7 +123,7 @@ internal fun RowScope.SendCashButton( label = "send button symbol size", ) - val shape = if (canType) CodeTheme.shapes.medium else CodeTheme.shapes.small + val shape = if (canType) CircleShape else CodeTheme.shapes.small Row( modifier = Modifier @@ -129,17 +131,17 @@ internal fun RowScope.SendCashButton( // Match the chat input's single-line height (~54dp; driven by its trailing send-icon // stack). Equal min width keeps the collapsed "$" state a square with even padding // instead of a short rectangle; the expanded "Send $" content grows past it. - .defaultMinSize(minWidth = 54.dp, minHeight = 54.dp) + .defaultMinSize(minWidth = ChatInputDefaults.OutsideSize, minHeight = ChatInputDefaults.OutsideSize) .clip(shape) // Haze sits under the fill β€” hidden while the background is opaque white, revealed // naturally as the background eases to transparent when typing begins. .hazeBlur(HazeInput.Sources(hazeState), hazeMaterial) .background(backgroundColor, shape) - .border(CodeTheme.dimens.border, borderColor, shape) + .then(if (isTyping) Modifier.border(CodeTheme.dimens.border, ChatInputDefaults.RimBrush, shape) else Modifier) .clickable(onClick = onClick) .padding( - horizontal = CodeTheme.dimens.grid.x3, - vertical = CodeTheme.dimens.grid.x2, + horizontal = CodeTheme.dimens.staticGrid.x3, + vertical = CodeTheme.dimens.staticGrid.x2, ), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, @@ -188,7 +190,10 @@ internal fun RowScope.SendCashButton( color = contentColor, // Base style stays textMedium; the animated fontSize carries it up to textLarge in // the condensed state so the symbol matches the "Send " prefix when expanded. - style = CodeTheme.typography.textMedium.copy(fontSize = symbolFontSize.sp), + style = CodeTheme.typography.textMedium.copy( + fontSize = symbolFontSize.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, + ), maxLines = 1, softWrap = false, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatComposerPhotosTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatComposerPhotosTest.kt new file mode 100644 index 0000000000..670e5a3e2d --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatComposerPhotosTest.kt @@ -0,0 +1,94 @@ +package com.flipcash.app.messenger.internal + +import android.net.Uri +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.media.ChatMediaUploadState +import com.flipcash.shared.chat.ui.media.MAX_STAGED_PHOTOS +import com.getcode.opencode.model.core.bytes +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** The composer's photo chips are plain state, so the reducer holds the rules for them. */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class ChatComposerPhotosTest { + + private val chatId = ChatId(UUID.fromString("0189d5f1-3c6a-7b4e-8f21-9c3d4e5f6a7b").bytes) + private val base = ChatViewModel.State(chatId = chatId) + + private fun photo(id: String) = ChatViewModel.StagedPhoto(id, Uri.parse("content://media/$id")) + + private fun reduce(state: ChatViewModel.State, event: ChatViewModel.Event) = + ChatViewModel.updateStateForEvent(event)(state) + + private fun stage(state: ChatViewModel.State, vararg ids: String) = + ids.fold(state) { acc, id -> reduce(acc, ChatViewModel.Event.PhotoStaged(photo(id))) } + + private val failed = ChatMediaUploadState.Failed(IllegalStateException("x"), retryable = true) + + @Test + fun stagingAddsChip() { + val state = stage(base, "a") + assertEquals(listOf("a"), state.composerPhotos.map { it.id }) + } + + @Test + fun chipsKeepStagingOrder() { + val state = stage(base, "a", "b", "c") + assertEquals(listOf("a", "b", "c"), state.composerPhotos.map { it.id }) + } + + @Test + fun removingDropsOnlyThatChip() { + val state = reduce(stage(base, "a", "b", "c"), ChatViewModel.Event.RemovePhoto("b")) + assertEquals(listOf("a", "c"), state.composerPhotos.map { it.id }) + } + + @Test + fun refusesChipPastMax() { + val ids = (1..MAX_STAGED_PHOTOS + 2).map { "p$it" }.toTypedArray() + val state = stage(base, *ids) + assertEquals(MAX_STAGED_PHOTOS, state.composerPhotos.size) + assertEquals("p$MAX_STAGED_PHOTOS", state.composerPhotos.last().id) + } + + @Test + fun chipsAloneEnableSend() { + assertFalse(base.canSendComposer(hasText = false)) + assertTrue(stage(base, "a").canSendComposer(hasText = false)) + assertTrue(base.canSendComposer(hasText = true)) + } + + @Test + fun failedChipDisablesSend() { + val state = reduce( + stage(base, "a", "b"), + ChatViewModel.Event.UploadStatesChanged(mapOf("a" to failed)), + ) + assertFalse(state.canSendComposer(hasText = true)) + // Removing the failed chip frees the send. + assertTrue(reduce(state, ChatViewModel.Event.RemovePhoto("a")).canSendComposer(hasText = true)) + } + + @Test + fun sendClearsChips() { + val state = reduce(stage(base, "a", "b"), ChatViewModel.Event.PhotosSent(setOf("a", "b"))) + assertTrue(state.composerPhotos.isEmpty()) + } + + @Test + fun editModeHidesChips() { + val staged = stage(base, "a") + val editing = staged.copy(editing = ChatViewModel.EditingMessage(1L, "hi", "")) + assertTrue(editing.composerPhotos.isEmpty()) + assertFalse(editing.acceptsMedia) + // Staged photos are held, not dropped, while editing. + assertEquals(1, editing.copy(editing = null).composerPhotos.size) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt index 7e4e0b4019..f7c6fb8bf2 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt @@ -78,7 +78,9 @@ class ChatGroupAnalyticsTest { @get:Rule var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) - private val chatCoordinator = mockk(relaxed = true) + private val chatCoordinator = mockk(relaxed = true) { + every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() + } private val contactCoordinator = mockk(relaxed = true) private val contactPaymentDelegate = mockk(relaxed = true) private val tipPaymentDelegate = mockk(relaxed = true) @@ -133,6 +135,7 @@ class ChatGroupAnalyticsTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, + mediaUploads = noMediaUploads(), e2eePolicy = E2eePolicy(), contactCoordinator = contactCoordinator, contactPaymentDelegate = contactPaymentDelegate, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt index a5cbcc9cfd..8ef5e29cf6 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt @@ -75,7 +75,9 @@ class ChatGroupCashLinkTest { @get:Rule var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) - private val chatCoordinator = mockk(relaxed = true) + private val chatCoordinator = mockk(relaxed = true) { + every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() + } private val contactCoordinator = mockk(relaxed = true) private val contactPaymentDelegate = mockk(relaxed = true) private val tipPaymentDelegate = mockk(relaxed = true) @@ -137,6 +139,7 @@ class ChatGroupCashLinkTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, + mediaUploads = noMediaUploads(), e2eePolicy = E2eePolicy(), contactCoordinator = contactCoordinator, contactPaymentDelegate = contactPaymentDelegate, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt index f401275083..acea67ae51 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt @@ -57,7 +57,9 @@ class ChatMentionPickerTest { @get:Rule var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) - private val chatCoordinator = mockk(relaxed = true) + private val chatCoordinator = mockk(relaxed = true) { + every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() + } private val userManager = mockk(relaxed = true) private val exchange = mockk(relaxed = true) private val transactionController = mockk(relaxed = true) @@ -84,6 +86,7 @@ class ChatMentionPickerTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, + mediaUploads = noMediaUploads(), e2eePolicy = E2eePolicy(), contactCoordinator = mockk(relaxed = true), contactPaymentDelegate = mockk(relaxed = true), diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt index fc85f395b0..c1c9be864b 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt @@ -52,7 +52,9 @@ class ChatOpenTranscriptTest { @get:Rule var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) - private val chatCoordinator = mockk(relaxed = true) + private val chatCoordinator = mockk(relaxed = true) { + every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() + } private val tipPaymentDelegate = mockk(relaxed = true) private val transactionController = mockk(relaxed = true) private val exchange = mockk(relaxed = true) @@ -82,6 +84,7 @@ class ChatOpenTranscriptTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, + mediaUploads = noMediaUploads(), e2eePolicy = E2eePolicy(), contactCoordinator = mockk(relaxed = true), contactPaymentDelegate = mockk(relaxed = true), diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt index eb658de072..663a96282d 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt @@ -73,7 +73,9 @@ class ChatSendFailureAnalyticsTest { @get:Rule var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) - private val chatCoordinator = mockk(relaxed = true) + private val chatCoordinator = mockk(relaxed = true) { + every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() + } private val contactCoordinator = mockk(relaxed = true) private val contactPaymentDelegate = mockk(relaxed = true) private val tipPaymentDelegate = mockk(relaxed = true) @@ -125,6 +127,7 @@ class ChatSendFailureAnalyticsTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, + mediaUploads = noMediaUploads(), e2eePolicy = E2eePolicy(), contactCoordinator = contactCoordinator, contactPaymentDelegate = contactPaymentDelegate, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/NoMediaUploads.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/NoMediaUploads.kt new file mode 100644 index 0000000000..6d3bae7bdf --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/NoMediaUploads.kt @@ -0,0 +1,11 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.shared.chat.media.ChatMediaUploads +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.emptyFlow + +/** A [ChatMediaUploads] with nothing staged, for view model tests that are not about photos. */ +internal fun noMediaUploads(): ChatMediaUploads = mockk(relaxed = true) { + every { allStates } returns emptyFlow() +} diff --git a/apps/flipcash/shared/chat-ui/build.gradle.kts b/apps/flipcash/shared/chat-ui/build.gradle.kts index 9fddba812b..863769d636 100644 --- a/apps/flipcash/shared/chat-ui/build.gradle.kts +++ b/apps/flipcash/shared/chat-ui/build.gradle.kts @@ -27,6 +27,14 @@ dependencies { api(project(":apps:flipcash:shared:common-ui")) implementation(project(":apps:flipcash:shared:theme")) + // CameraX for ChatCameraPanel, the same artifacts :ui:scanner uses. + implementation(libs.androidx.camerax.core) + implementation(libs.androidx.camerax.camera2) + implementation(libs.androidx.camerax.lifecycle) + implementation(libs.androidx.camerax.view) + implementation(project(":libs:permissions:public")) + implementation(libs.compose.activities) + testImplementation(libs.robolectric) testImplementation(libs.bundles.unit.testing) } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index 2ed6e2b868..4fd48c9484 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -8,6 +8,9 @@ import com.getcode.solana.keys.Mint sealed interface ChatAction { data class RetryMessage(val bubble: ChatListItem.ContentBubble) : ChatAction + + /** Opens the full-screen viewer for the photo [bubble] carries. */ + data class OpenPhoto(val bubble: ChatListItem.ContentBubble) : ChatAction data class AdvanceReadPointer(val messageId: Long) : ChatAction object RefreshContact : ChatAction /** diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index fcb7886f04..7a0548b6f6 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -151,6 +151,8 @@ sealed interface ChatListItem { * [undecryptableHint][com.flipcash.shared.chat.ui.undecryptableHint]. */ val undecryptableHint: UndecryptableHint = UndecryptableHint.UpdateApp, + /** What a photo message needs beyond its content to load and gate its photo. */ + val photo: PhotoMessageContext? = null, ) : ChatListItem { /** * Who this bubble is attributed to, for grouping. [senderId] is the answer whenever the @@ -322,3 +324,25 @@ private fun Char.isPunctuation(): Boolean = when (category) { /** Whether a text row would hold anything but whitespace and stray punctuation. */ private fun String.saysSomething(): Boolean = any { !it.isWhitespace() && !it.isPunctuation() } + +/** + * What a photo bubble needs besides its [MessageContent.Media]: the chat the blob is fetched + * under, whether its bytes are sealed, and the two reasons to draw only its BlurHash. + * + * @property redacted the message was redacted, so its photo is never fetched. + * @property previewing the viewer is reading a group they have not joined, which may show the + * transcript but not its media. + */ +data class PhotoMessageContext( + val chatId: com.flipcash.services.models.chat.ChatId, + /** + * The author, own id included. [ChatListItem.Message.senderId] is null for own messages, but + * opening a sealed photo needs the real sender to pick the key roles. + */ + val senderId: ID? = null, + val sealed: Boolean, + val redacted: Boolean, + val previewing: Boolean, +) { + val blurhashOnly: Boolean get() = redacted || previewing +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatQuote.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatQuote.kt index 99ca359cb6..3c0ccd1123 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatQuote.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatQuote.kt @@ -1,5 +1,9 @@ package com.flipcash.shared.chat.models +import com.flipcash.services.models.chat.ChatId +import com.getcode.opencode.model.core.ID + +import com.flipcash.services.models.chat.MediaItemRendition import androidx.compose.ui.graphics.Color import com.getcode.opencode.model.financial.Fiat @@ -52,4 +56,23 @@ sealed interface ChatQuoteSnippet { * discussed. */ data class Cash(val amount: Fiat, val tokenName: String) : ChatQuoteSnippet + + /** + * A quoted photo: a thumbnail beside its caption, or beside the word "Photo" when there is none. + * + * @property caption the caption as written, null when the photo has none. + * @property rendition what a thumbnail is drawn from, null when the item carries none. + * @property sealed whether the photo's bytes must be opened before they decode. + * @property redacted a redacted photo is never fetched, so its thumbnail is not drawn. + */ + data class Photo( + val caption: String?, + val rendition: MediaItemRendition?, + val sealed: Boolean, + val redacted: Boolean, + /** Which chat the photo's blob belongs to, for the thumbnail's fetch. */ + val chatId: ChatId? = null, + /** Whose key a sealed photo opens with; null for the viewer's own. */ + val senderId: ID? = null, + ) : ChatQuoteSnippet } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt index fc0a3b44a1..0168df2615 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt @@ -62,6 +62,10 @@ object ChatAnimations { // Action bar <-> composer swap β€” scale from 0.95 + opacity. val composerSwap: SpringSpec = spring(dampingRatio = 0.69f, stiffness = Spring.StiffnessHigh) + // Controls that pop in and out in place: the camera's Β·Β·Β· stack and the + returning after a + // capture. Scale from [reactionEnterScale] + opacity. iOS swap: .spring(duration: 0.27, bounce: 0.31). + val swap: SpringSpec = spring(dampingRatio = 0.69f, stiffness = 541f) + // Delivered label appearance β€” scale from 0.95 + opacity. // Matches iOS deliveredSpring: .spring(duration: 0.4, bounce: 0.12). val delivered: SpringSpec = spring(dampingRatio = 0.88f, stiffness = 250f) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt index 6f6c5c1bce..6cd4331038 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt @@ -1,5 +1,9 @@ package com.flipcash.shared.chat.ui +import com.flipcash.shared.chat.ui.media.QuotedPhotoThumbnail +import com.flipcash.core.R +import androidx.compose.ui.res.stringResource +import com.flipcash.shared.chat.media.ChatMediaText import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement @@ -51,6 +55,7 @@ fun ChatQuotePanel( ) { val accent = quote.accent ?: CodeTheme.colors.tertiary val name = quote.nameAccent ?: accent + val photo = (quote.snippet as? ChatQuoteSnippet.Photo)?.takeIf { !it.redacted } Row( // Intrinsic minimum, so the rule can fill a height the text column decides. Without it a @@ -86,11 +91,11 @@ fun ChatQuotePanel( Column( modifier = Modifier .padding( - end = QuotePanelDefaults.trailingPadding, + // The thumbnail's own gap replaces the trailing one when it follows the text. + end = if (photo != null) 0.dp else QuotePanelDefaults.trailingPadding, top = QuotePanelDefaults.verticalPadding, bottom = QuotePanelDefaults.verticalPadding, ), - verticalArrangement = Arrangement.spacedBy(QuotePanelDefaults.nameGap), ) { Text( text = quote.authorName, @@ -111,6 +116,14 @@ fun ChatQuotePanel( overflow = TextOverflow.Ellipsis, ) + is ChatQuoteSnippet.Photo -> Text( + text = ChatMediaText.preview(snippet.caption, stringResource(R.string.label_chat_media_photo)), + style = CodeTheme.typography.caption, + color = Color.White.copy(alpha = QuotePanelDefaults.snippetAlpha), + maxLines = QuotePanelDefaults.snippetMaxLines, + overflow = TextOverflow.Ellipsis, + ) + // A quoted payment shows what identifies it, not just a number. is ChatQuoteSnippet.Cash -> { val exchange = LocalExchange.current @@ -146,6 +159,11 @@ fun ChatQuotePanel( } } } + + // At the trailing edge, centred on the panel, as iOS places it. + if (photo != null) { + QuotedPhotoThumbnail(photo, Modifier.padding(end = QuotePanelDefaults.cashGap)) + } } } @@ -189,9 +207,11 @@ private object QuotePanelDefaults { val cashGap: Dp @Composable get() = CodeTheme.dimens.staticGrid.x1 - val nameGap = 1.dp - val accentWidth = 3.dp - val flagSize = 14.dp + /** The accent bar is a rule, not a gutter, so it takes the theme's thick border. */ + val accentWidth: Dp + @Composable get() = CodeTheme.dimens.thickBorder + val flagSize: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x3 const val groundAlpha = 0.14f const val snippetAlpha = 0.55f const val amountAlpha = 0.75f diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt index e588d4f309..fc6622756d 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt @@ -7,6 +7,7 @@ import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.WidgetContent import com.flipcash.services.models.handle import com.flipcash.shared.chat.ChatSummary +import com.flipcash.shared.chat.media.ChatMediaText import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Token import com.getcode.solana.keys.Mint @@ -180,8 +181,16 @@ private fun MessageContent.previewText( is MessageContent.Deleted -> null is MessageContent.Encrypted -> null - // TODO: - is MessageContent.Media -> null + // The caption, else "Photo", behind the camera emoji; prefixed like a typed message. + is MessageContent.Media -> { + val message = ChatMediaText.preview(caption?.text, resources.getString(R.string.label_chat_media_photo)) + when { + sentBySelf -> resources.getString(R.string.label_chat_preview_sentMessage, message) + senderName != null -> + resources.getString(R.string.label_chat_preview_senderMessage, senderName, message) + else -> message + } + } is MessageContent.System -> null // A widget this client can't draw previews as nothing, as an undecryptable message does. diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt index 61472ca8a9..ca191d091f 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt @@ -1,5 +1,7 @@ package com.flipcash.shared.chat.ui +import com.flipcash.shared.chat.ui.media.QuotedPhotoThumbnail +import com.flipcash.shared.chat.media.ChatMediaText import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -30,7 +32,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp import com.flipcash.core.R import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.shared.chat.models.ChatQuoteSnippet @@ -113,7 +115,6 @@ fun ComposerReplyStrip( modifier = Modifier .weight(1f) .padding(vertical = ComposerReplyStripDefaults.textPadding), - verticalArrangement = Arrangement.spacedBy(ComposerReplyStripDefaults.nameGap), ) { Text( text = quote.authorName, @@ -125,6 +126,11 @@ fun ComposerReplyStrip( QuoteLine(quote.snippet) } + // After the text and before the way out, centred on the row, as iOS lays it. Nothing for a + // redacted photo or any other kind of quote. + val photo = quote.snippet as? ChatQuoteSnippet.Photo + if (photo != null && !photo.redacted) QuotedPhotoThumbnail(photo) + // A disc rather than a bare βœ•, in a hit target wider than the disc. Box( modifier = Modifier @@ -170,6 +176,15 @@ private fun QuoteLine(snippet: ChatQuoteSnippet) { overflow = TextOverflow.Ellipsis, ) + // Plain text: the thumbnail is its own item in the strip's row, not part of this line. + is ChatQuoteSnippet.Photo -> Text( + text = ChatMediaText.preview(snippet.caption, stringResource(R.string.label_chat_media_photo)), + style = style, + color = CodeTheme.colors.textMain, + maxLines = ComposerReplyStripDefaults.textMaxLines, + overflow = TextOverflow.Ellipsis, + ) + // A payment carries the flag and the mint's name the cash card leads with. The amount alone // reads as a number; with the flag beside it, it reads as the payment being answered. is ChatQuoteSnippet.Cash -> { @@ -205,22 +220,31 @@ private fun QuoteLine(snippet: ChatQuoteSnippet) { } } -/** Every measurement the strip makes, carried over from iOS. */ +/** Every measurement the strip makes, on the grid; iOS's values, to the nearest step. */ private object ComposerReplyStripDefaults { - val ruleWidth = 6.dp - val gap = 9.dp - val nameGap = 2.dp - val cashGap = 6.dp - val textPadding = 8.dp - val trailingPadding = 8.dp + val ruleWidth: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x1 + val gap: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x2 + val cashGap: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x1 + val textPadding: Dp + @Composable get() = ChatInputDefaults.FieldPadding + val trailingPadding: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x2 /** The composer field's own height, so a one-line quote does not sit shorter than it. */ - val minHeight = 50.dp + val minHeight: Dp + @Composable get() = ChatInputDefaults.OutsideSize /** Sized to the cap height of the amount beside it, so the flag reads as a mark on the line. */ - val flagSize = 16.dp - val dismissTarget = 34.dp - val dismissDisc = 22.dp - val dismissGlyph = 11.dp + val flagSize: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x3 + val dismissTarget: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x7 + val dismissDisc: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x4 + val dismissGlyph: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x2 const val textMaxLines = 2 } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index d0fe4797ef..c5b0373164 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -70,6 +70,8 @@ import com.flipcash.shared.chat.models.LinkCard import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.models.LocalChatActionHandler +import com.flipcash.shared.chat.ui.media.MediaMessageBubble +import com.flipcash.shared.chat.ui.media.photoBody import com.flipcash.shared.chat.models.MessagePart import com.flipcash.shared.chat.models.splitAroundLinkCard import kotlin.time.Instant @@ -242,7 +244,20 @@ fun ContentBubble( // A reply is a text bubble with a citation above the body. Routing it through // the same composable keeps its grouping, edited marker and link handling // identical to any other message, which is what it is. - is MessageContent.Reply -> TextBubble( + is MessageContent.Reply -> content.photoBody()?.let { photo -> + MediaMessageBubble( + item = item, + body = photo, + position = position, + maxWidth = bubbleMaxWidth, + modifier = modifier, + quote = quote, + onQuoteClick = onQuoteClick, + interactive = interactive, + onLongClick = onLongClick?.takeIf { interactive }, + onDoubleClick = onDoubleClick?.takeIf { interactive }, + ) + } ?: TextBubble( modifier = modifier, text = item.partText ?: content.linkableText().orEmpty(), mentions = item.mentions, @@ -268,8 +283,16 @@ fun ContentBubble( hint = item.undecryptableHint, ) - // TODO - is MessageContent.Media -> Unit + is MessageContent.Media -> MediaMessageBubble( + item = item, + body = content, + position = position, + maxWidth = bubbleMaxWidth, + modifier = modifier, + interactive = interactive, + onLongClick = onLongClick?.takeIf { interactive }, + onDoubleClick = onDoubleClick?.takeIf { interactive }, + ) is MessageContent.System -> Unit // A widget variant this client doesn't know draws as any message it can't show. is MessageContent.Widget -> when (val widget = content.widget) { diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/AttachSurface.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/AttachSurface.kt new file mode 100644 index 0000000000..1d24e55664 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/AttachSurface.kt @@ -0,0 +1,1099 @@ +package com.flipcash.shared.chat.ui.media + +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.material.icons.outlined.Add +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.layout +import androidx.compose.foundation.Image +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import android.app.Dialog +import android.net.Uri +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.foundation.layout.fillMaxSize +import android.content.Context +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.view.Gravity +import android.view.View +import android.view.Window +import android.view.WindowManager +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.Animatable +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCompositionContext +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.lerp +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.findViewTreeViewModelStoreOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.savedstate.findViewTreeSavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import androidx.compose.ui.util.lerp +import kotlin.math.abs +import kotlin.math.exp +import kotlin.math.roundToInt + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Payments +import androidx.compose.material.icons.outlined.PhotoCamera +import androidx.compose.material.icons.outlined.PhotoLibrary +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.Dp +import androidx.compose.runtime.Immutable +import com.getcode.ui.components.chat.ChatInputDefaults +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.core.R +import com.getcode.theme.CodeTheme + +/** The most photos a message can carry; media rows go away once this many are staged. */ +const val MAX_STAGED_PHOTOS = 10 + +/** A row in the attach menu. */ +enum class AttachRow { Cash, Camera, Photos } + +/** + * The rows the attach menu lists, in order: Cash when the chat offers it, then Camera and Photos. + * Camera and Photos need a chat that [acceptsMedia] and room for more than [stagedCount] photos. + */ +fun attachRows(cashOffered: Boolean, acceptsMedia: Boolean, stagedCount: Int): List = + buildList { + if (cashOffered) add(AttachRow.Cash) + if (acceptsMedia && stagedCount < MAX_STAGED_PHOTOS) { + add(AttachRow.Camera) + add(AttachRow.Photos) + } + } + +internal fun attachRowTag(row: AttachRow): String = "attach_row_${row.name.lowercase()}" + +/** + * The small card listing [rows]. Separate from [AttachPopover] so it can be placed by something + * other than a popup window, and tested without one. + */ +@Composable +fun AttachMenu( + rows: List, + onRowClick: (AttachRow) -> Unit, + modifier: Modifier = Modifier, +) { + val metrics = attachMetrics() + val shape = RoundedCornerShape(metrics.menuCorner) + AttachMenuRows( + rows = rows, + onRowClick = onRowClick, + modifier = modifier + .shadow(CodeTheme.dimens.staticGrid.x2, shape) + .clip(shape) + .background(CodeTheme.colors.surface) + .width(metrics.menuWidth), + ) +} + +/** The rows alone, on whatever surface the host draws. */ +@Composable +internal fun AttachMenuRows( + rows: List, + onRowClick: (AttachRow) -> Unit, + modifier: Modifier = Modifier, +) { + val metrics = attachMetrics() + val grid = CodeTheme.dimens.staticGrid + Column(modifier = modifier.padding(vertical = metrics.menuVerticalPadding)) { + rows.forEach { row -> + Row( + modifier = Modifier + .fillMaxWidth() + .testTag(attachRowTag(row)) + .clickable(role = Role.Button) { onRowClick(row) } + .height(metrics.menuRowHeight) + .padding(horizontal = grid.x2), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(grid.x8) + .background(Color.White.copy(alpha = 0.08f), CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = row.icon, + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(grid.x4), + ) + } + Spacer(Modifier.width(grid.x3)) + Text( + text = stringResource(row.label), + style = CodeTheme.typography.textMedium.copy(fontSize = 18.sp), + color = CodeTheme.colors.textMain, + ) + } + } + } +} + +private val AttachRow.icon: ImageVector + get() = when (this) { + AttachRow.Cash -> Icons.Outlined.Payments + AttachRow.Camera -> Icons.Outlined.PhotoCamera + AttachRow.Photos -> Icons.Outlined.PhotoLibrary + } + +private val AttachRow.label: Int + get() = when (this) { + AttachRow.Cash -> R.string.action_chatAttachCash + AttachRow.Camera -> R.string.action_chatAttachCamera + AttachRow.Photos -> R.string.action_chatAttachPhotos + } + +/** + * The attach surface's measurements, from the theme's grid so they follow the screen's size class. + * The menu's width and row metrics follow iOS's `AttachSurfaceLayout`, each at the nearest grid step. + * [fieldPadding], [barContentHeight] and the chip's size and corner are the composer's own, read + * from the same tokens so the menu stays aligned with the field and a capture lands on its chip. + */ +@Immutable +internal data class AttachMetrics( + val menuWidth: Dp, + val menuRowHeight: Dp, + val menuVerticalPadding: Dp, + val menuCorner: Dp, + /** The composer field's padding: the menu's leading edge sits this far out from "+"'s. */ + val fieldPadding: Dp, + /** The menu reaches out past the field to this inset from the screen's edge, over the "$" button. */ + val menuLeadingInset: Dp, + /** Height of a one-line composer field plus its padding: iOS `BarMetrics.contentHeight`. */ + val barContentHeight: Dp, + /** How far the open menu reaches above the composer field's top edge. */ + val fieldTopOverhang: Dp, + /** How far past "+"'s size the surface has fully turned from "+"'s fill into the glass. */ + val plusBlendDistance: Dp, + val cardFallbackHeight: Dp, + /** The margin between the card and the screen's sides and bottom: iOS `AttachOverlayLayout.cardInset`. */ + val cardInset: Dp, + val cardCorner: Dp, + /** A staged chip's radius, which a landing ends on. */ + val chipCorner: Dp, + val chipSize: Dp, +) + +@Composable +internal fun attachMetrics(): AttachMetrics { + val grid = CodeTheme.dimens.staticGrid + return AttachMetrics( + menuWidth = grid.x20 * 2 + grid.x4, + menuRowHeight = grid.x12, + menuVerticalPadding = grid.x2, + menuCorner = grid.x3, + fieldPadding = ChatInputDefaults.FieldPadding, + menuLeadingInset = grid.x3, + barContentHeight = ChatInputDefaults.OutsideSize, + fieldTopOverhang = grid.x2, + plusBlendDistance = grid.x8, + cardFallbackHeight = grid.x20 * 5, + cardInset = grid.x2, + cardCorner = grid.x5, + chipCorner = PhotoChipDefaults.Corner, + chipSize = PhotoChipDefaults.Size, + ) +} +// How quickly the shot's aim catches up with where the chip is: about two frames behind it. +private const val ShotAimTauNanos = 30_000_000f +private const val ShotProgressThreshold = 0.001f +private const val BlurRadiusPx = 40 +private val Tint = Color(0xFF48484A) +// Over a blur the tint stays mostly opaque: the blur isn't drawn on every device that reports it +// enabled (emulators, some GPUs), and at 0.4 the composer showed through sharp behind the rows. +private const val GlassTintAlpha = 0.85f +private const val SolidTintAlpha = 0.97f + +internal fun menuHeight(rowCount: Int, rowHeight: Dp, verticalPadding: Dp): Dp = rowHeight * rowCount + verticalPadding * 2 + +/** + * The menu's top-left in screen pixels for a "+" at [plus]: its leading edge on the composer + * field's, centred on "+"'s bottom edge, lifted to clear the field's top by `overhangPx`. + * Port of iOS `AttachOverlayLayout.panelFrame`. + */ +internal fun menuOrigin(plus: Rect, heightPx: Float, fieldPaddingPx: Float, contentHeightPx: Float, overhangPx: Float, leadingPx: Float): Offset { + val fieldTop = plus.bottom + fieldPaddingPx - contentHeightPx + val y = minOf(plus.bottom - heightPx / 2f, fieldTop - overhangPx) + // iOS reaches the leading edge out to `leadingPx` from the screen edge (menuLeadingReach). + return Offset(leadingPx, y) +} + +/** + * [menu] lifted, if need be, so its bottom stands no lower than [limitBottom]. With the keyboard + * down "+" is close to the screen's bottom and the menu, which reaches below it, would run off. + */ +internal fun menuClampedAbove(menu: Rect, limitBottom: Float): Rect = + if (menu.bottom <= limitBottom) menu else menu.translate(0f, limitBottom - menu.bottom) + +/** + * Where the menu's bottom may reach: over the keyboard the screen's bottom, as iOS lets it hang + * over the keys; with the keyboard down the composer's bottom edge, so it sits level with the field. + */ +internal fun menuLimitBottom(screenBottom: Float, composerBottom: Float, keyboardUp: Boolean): Float = + if (keyboardUp) screenBottom else minOf(screenBottom, composerBottom) + +/** + * How much of the frosted glass shows at [rect] against "+"'s flat fill: none at "+"'s size, all of + * it once the surface is [blendPx] bigger, so the two meet without a colour jump. + */ +internal fun attachGlassAmount(rect: Rect, plus: Rect, blendPx: Float): Float { + val growth = maxOf(rect.width - plus.width, rect.height - plus.height) + return (growth / blendPx).coerceIn(0f, 1f) +} + +/** What the attach surface is showing. */ +enum class AttachPhase { + /** Shrunk into "+" and gone: on its way in or out. */ + Collapsed, + + /** The menu's rows. */ + Menu, + + /** The camera card. */ + Camera, + + /** Shrinking onto the chip a capture was staged as, then gone. */ + Landing, +} + +/** The camera card's share of the screen's height, and its height before a screen is measured. */ +internal const val CardHeightFraction = 0.58f + +/** + * The card's frame in a screen [width] by [height] pixels: [heightPx] tall (see [cardHeight]), + * standing [bottomInsetPx] above the screen's bottom and [insetPx] in from its sides. + * Port of iOS `AttachOverlayLayout.cardFrame`. + */ +internal fun cardRect(width: Float, height: Float, insetPx: Float, bottomInsetPx: Float, heightPx: Float): Rect { + val w = (width - insetPx * 2).coerceAtLeast(0f) + val h = heightPx.coerceAtMost((height - bottomInsetPx).coerceAtLeast(0f)) + return Rect(insetPx, height - bottomInsetPx - h, insetPx + w, height - bottomInsetPx) +} + +/** The card's height on a screen [screenHeightPx] tall: 58% of it, rounded; iOS `AttachCard.height`. */ +internal fun cardHeight(screenHeightPx: Float, fallbackPx: Float): Float = + if (screenHeightPx > 0f) (screenHeightPx * CardHeightFraction).roundToInt().toFloat() else fallbackPx + +/** The frames the surface moves between, in screen pixels. Read in layout, so a plain holder. */ +private class SurfaceGeometry { + var plus = Rect.Zero + var menu = Rect.Zero + var card = Rect.Zero + var windowOrigin = Offset.Zero +} + +/** + * The attach surface: one frosted shape that grows out of "+" into the menu, morphs into the + * camera card and back, and shrinks onto the chip a capture was staged as. Only its frame and + * corner radius move; the rows and the camera sit at their resting frames inside the clip and + * cross-fade (the leaving side early, the arriving side late). Port of iOS `AttachSurface`. + * + * It lives over the keyboard. A normal popup sits below the IME and a focusable one takes focus + * from the field, so the surface is in non-focusable windows, which are layered above the IME and + * leave the field focused: a full-screen window that draws the content and swallows outside taps, + * and, where cross-window blur is on, a backdrop window under it that tracks the shape and carries + * the blur. Because they are non-focusable they get no key events; Back is taken through the + * activity's dispatcher at overlay priority, which runs before the IME's own Back handling, so the + * keyboard stays. The content window is added once, hidden, when the surface is first composed: + * adding it on the tap cost the morph its first frames. + * + * The camera is mounted, hidden, once the menu has finished opening when access is already + * granted, so it is streaming by the time the menu opens into it. + * + * [onShutter] is the host's cue to stage the shot (its chip is what the card lands on) before + * [onCaptured] says the file is written. + * + * [plus] and [landing] are queried every frame, since "+" slides with the keyboard through layer + * transforms that `onGloballyPositioned` does not report. [onBack] is Back: the host steps the + * [phase] back (camera to menu, menu to closed). [onGone] fires once the surface has finished + * leaving, whichever way it went. + */ +@Composable +fun AttachSurface( + phase: AttachPhase, + rows: List, + plus: () -> LayoutCoordinates?, + landing: () -> LayoutCoordinates?, + onRowClick: (AttachRow) -> Unit, + onBack: () -> Unit, + onDismissRequest: () -> Unit, + onShutter: (preview: ImageBitmap?, uri: Uri) -> Unit, + onCaptured: (Uri) -> Unit, + onCameraError: (Throwable) -> Unit, + onGone: () -> Unit, + onCoverChange: (Boolean) -> Unit = {}, +) { + var windowShown by remember { mutableStateOf(false) } + LaunchedEffect(phase) { if (phase != AttachPhase.Collapsed) windowShown = true } + + val takesBack = phase == AttachPhase.Menu || phase == AttachPhase.Camera + val currentBack by rememberUpdatedState(onBack) + BackHandler(enabled = takesBack) { currentBack() } + val hostView = LocalView.current + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + DisposableEffect(takesBack, hostView) { + val dispatcher = hostView.findActivity()?.onBackInvokedDispatcher + val callback = android.window.OnBackInvokedCallback { currentBack() } + if (takesBack && dispatcher != null) { + dispatcher.registerOnBackInvokedCallback(android.window.OnBackInvokedDispatcher.PRIORITY_OVERLAY, callback) + } + onDispose { dispatcher?.unregisterOnBackInvokedCallback(callback) } + } + } + + val parentContext = rememberCompositionContext() + val host = remember(hostView) { AttachWindow(hostView) } + // What the window shows: the open surface, or nothing between opens. + var session by remember { mutableStateOf<(@Composable () -> Unit)?>(null) } + DisposableEffect(host) { + host.show(parentContext) { session?.invoke() } + onDispose { host.dismiss() } + } + + if (!windowShown) return + val currentGone by rememberUpdatedState(onGone) + val currentCover by rememberUpdatedState(onCoverChange) + AttachSurfaceWindow( + host = host, + setSession = { session = it }, + phase = phase, + rows = rows, + plus = plus, + landing = landing, + onRowClick = onRowClick, + onBack = onBack, + onDismissRequest = onDismissRequest, + onShutter = onShutter, + onCaptured = onCaptured, + onCameraError = onCameraError, + onCovering = { currentCover(true) }, + onUncovering = { currentCover(false) }, + onGone = { + currentCover(false) + windowShown = false + currentGone() + }, + ) +} + +@Composable +private fun AttachSurfaceWindow( + host: AttachWindow, + setSession: ((@Composable () -> Unit)?) -> Unit, + phase: AttachPhase, + rows: List, + plus: () -> LayoutCoordinates?, + landing: () -> LayoutCoordinates?, + onRowClick: (AttachRow) -> Unit, + onBack: () -> Unit, + onDismissRequest: () -> Unit, + onShutter: (preview: ImageBitmap?, uri: Uri) -> Unit, + onCaptured: (Uri) -> Unit, + onCameraError: (Throwable) -> Unit, + onCovering: () -> Unit, + onUncovering: () -> Unit, + onGone: () -> Unit, +) { + val view = LocalView.current + val density = LocalDensity.current + val blurEnabled = rememberCrossWindowBlurEnabled(view) + val currentPhase by rememberUpdatedState(phase) + val currentPlus by rememberUpdatedState(plus) + val currentLanding by rememberUpdatedState(landing) + val currentRows by rememberUpdatedState(rows) + val currentOnRowClick by rememberUpdatedState(onRowClick) + val currentOnBack by rememberUpdatedState(onBack) + val currentOnDismiss by rememberUpdatedState(onDismissRequest) + val currentOnShutter by rememberUpdatedState(onShutter) + val currentOnCaptured by rememberUpdatedState(onCaptured) + val currentOnCameraError by rememberUpdatedState(onCameraError) + val currentOnGone by rememberUpdatedState(onGone) + val currentOnUncovering by rememberUpdatedState(onUncovering) + val currentOnCovering by rememberUpdatedState(onCovering) + + val rect = remember { Animatable(Rect.Zero, Rect.VectorConverter, Rect.VisibilityThreshold) } + val radius = remember { Animatable(0f) } + // The whole surface's opacity: it fades out at the end of a landing. + val fade = remember { Animatable(1f) } + val geometry = remember { SurfaceGeometry() } + var ready by remember { mutableStateOf(false) } + // The camera mounts once the menu has finished opening, so its start-up stays off the morph's + // frames; it still has the menu's dwell time to open before the card shows it. + var cameraWarm by remember { mutableStateOf(false) } + // The last preview frame at the shutter: the card shrinks this onto the chip, not the live feed. + var frozen by remember { mutableStateOf(null) } + // Closing onto "+": the bar's own "+" is back underneath, so the surface stops drawing one. + var handedBack by remember { mutableStateOf(false) } + + val metrics = attachMetrics() + val plusBlendPx = with(density) { metrics.plusBlendDistance.toPx() } + val cornerPx = with(density) { metrics.menuCorner.toPx() } + + DisposableEffect(host) { + setSession { + SurfaceContent( + phase = currentPhase, + rows = currentRows, + rect = rect, + radius = radius, + fade = fade, + geometry = geometry, + ready = ready, + blur = blurEnabled, + cameraWarm = cameraWarm, + handedBack = handedBack, + frozen = frozen, + plusBlendPx = plusBlendPx, + onRowClick = { currentOnRowClick(it) }, + onBack = { currentOnBack() }, + onDismiss = { currentOnDismiss() }, + onShutter = { preview, uri -> + frozen = preview + currentOnShutter(preview, uri) + }, + onCaptured = { currentOnCaptured(it) }, + onCameraError = { + frozen = null + currentOnCameraError(it) + }, + ) + } + host.reveal() + onDispose { + setSession(null) + host.conceal() + } + } + + LaunchedEffect(host, blurEnabled, metrics) { + val screen = IntArray(2) + val menuWidthPx = with(density) { metrics.menuWidth.roundToPx() } + val fieldPaddingPx = with(density) { metrics.fieldPadding.toPx() } + val contentHeightPx = with(density) { metrics.barContentHeight.toPx() } + val overhangPx = with(density) { metrics.fieldTopOverhang.toPx() } + val leadingPx = with(density) { metrics.menuLeadingInset.toPx() } + val cardInsetPx = with(density) { metrics.cardInset.toPx() } + val cardCornerPx = with(density) { metrics.cardCorner.toPx() } + val chipCornerPx = with(density) { metrics.chipCorner.toPx() } + val chipSizePx = with(density) { metrics.chipSize.toPx() } + val cardFallbackPx = with(density) { metrics.cardFallbackHeight.toPx() } + var initialized = false + var lastPhase: AttachPhase? = null + var lastTarget: Rect? = null + var job: Job? = null + var fading = false + var closing = false + var landingSince = 0L + var framesShown = 0 + // The shot's shrink runs once, from the shutter: its progress is sprung, and the frame is + // placed between where it started and an aim that eases after the chip's spot. Restarting + // a spring each time that spot moves (it rides the keyboard up) would stall the card. + val shotProgress = Animatable(0f) + var shotFrom: Rect? = null + var shotFromRadius = 0f + var aim = Rect.Zero + var lastFrameNanos = 0L + + while (true) { + val frameNanos = withFrameNanos { it } + val dtNanos = if (lastFrameNanos == 0L) 0L else (frameNanos - lastFrameNanos).coerceIn(0L, 50_000_000L) + lastFrameNanos = frameNanos + val plusCoordinates = currentPlus()?.takeIf { it.isAttached } ?: continue + view.getLocationOnScreen(screen) + val shift = Offset(screen[0].toFloat(), screen[1].toFloat()) + val plusRect = plusCoordinates.boundsInRoot().translate(shift) + val menuHeightPx = with(density) { menuHeight(currentRows.size.coerceAtLeast(1), metrics.menuRowHeight, metrics.menuVerticalPadding).roundToPx() } + val menuRect0 = Rect( + menuOrigin(plusRect, menuHeightPx.toFloat(), fieldPaddingPx, contentHeightPx, overhangPx, leadingPx), + Size(menuWidthPx.toFloat(), menuHeightPx.toFloat()), + ) + val origin = host.contentOrigin() + val size = host.contentSize() + val screenH = size.height + val menuRect = menuClampedAbove( + menuRect0, + limitBottom = menuLimitBottom( + screenBottom = origin.y + screenH - cardInsetPx - host.bottomSystemInsetPx(), + composerBottom = plusRect.bottom + fieldPaddingPx, + keyboardUp = host.keyboardUp(), + ), + ) + val card = cardRect( + width = size.width, + height = screenH, + insetPx = cardInsetPx, + bottomInsetPx = cardInsetPx + host.bottomSystemInsetPx(), + heightPx = cardHeight(screenH, cardFallbackPx), + ).translate(origin) + geometry.plus = plusRect + geometry.menu = menuRect + geometry.card = card + geometry.windowOrigin = origin + + val now = currentPhase + val plusRadius = plusRect.height / 2f + val chip = currentLanding()?.takeIf { it.isAttached }?.boundsInRoot()?.translate(shift) + // From the shutter on, the card shrinks the taken frame toward the chip. The photo is + // still being saved, so until its chip lays out it heads for where the chip will be, + // beside "+" and above it, and retargets onto the chip once there is one. + val shot = frozen != null && (now == AttachPhase.Camera || now == AttachPhase.Landing) + val towardChip = chip ?: Rect( + left = plusRect.center.x, + top = plusRect.top - chipSizePx, + right = plusRect.center.x + chipSizePx, + bottom = plusRect.top, + ) + val targetRect: Rect + val targetRadius: Float + when { + shot -> { targetRect = towardChip; targetRadius = chipCornerPx } + now == AttachPhase.Collapsed -> { targetRect = plusRect; targetRadius = plusRadius } + now == AttachPhase.Menu -> { targetRect = menuRect; targetRadius = cornerPx } + // Camera, or a landing without a shot frame: hold the card until the chip lays out. + chip != null && now == AttachPhase.Landing -> { targetRect = chip; targetRadius = chipCornerPx } + else -> { targetRect = card; targetRadius = cardCornerPx } + } + + if (!initialized) { + // Starts as "+" itself, so the surface grows out of it. + rect.snapTo(plusRect) + radius.snapTo(plusRadius) + initialized = true + ready = true + } + + // To or from the card the spring is iOS's `attachCard` (0.30s, no bounce): an overshoot + // on a card half the screen tall would carry past the screen. The menu's is bouncier. + val cardMotion = now == AttachPhase.Camera || now == AttachPhase.Landing || + lastPhase == AttachPhase.Camera + val damping = if (cardMotion) 1f else 0.69f + val stiffness = if (cardMotion) 439f else 541f + val spec = spring(damping, stiffness, Rect.VisibilityThreshold) + val radiusSpec = spring(damping, stiffness) + fun launchMorph() { + job?.cancel() + job = launch { + launch { radius.animateTo(targetRadius, radiusSpec) } + rect.animateTo(targetRect, spec) + } + } + if (shot) { + if (shotFrom == null) { + job?.cancel() + shotFrom = rect.value + shotFromRadius = radius.value + aim = towardChip + shotProgress.snapTo(0f) + job = launch { shotProgress.animateTo(1f, spring(damping, stiffness, ShotProgressThreshold)) } + } + aim = lerp(aim, towardChip, 1f - exp(-dtNanos / ShotAimTauNanos)) + val p = shotProgress.value + rect.snapTo(lerp(shotFrom!!, aim, p)) + radius.snapTo(lerp(shotFromRadius, chipCornerPx, p)) + lastPhase = now + lastTarget = targetRect + } else if (now != lastPhase) { + shotFrom = null + lastPhase = now + lastTarget = targetRect + launchMorph() + } else if (targetRect != lastTarget) { + lastTarget = targetRect + if (job?.isActive == true || now == AttachPhase.Landing || shot) { + launchMorph() + } else { + // Settled and "+" or the field moved with the keyboard: follow it exactly. + rect.snapTo(targetRect) + radius.snapTo(targetRadius) + } + } + + val aimed = shotFrom == null || ( + abs(aim.left - towardChip.left) < 0.5f && abs(aim.top - towardChip.top) < 0.5f && + abs(aim.width - towardChip.width) < 0.5f && abs(aim.height - towardChip.height) < 0.5f + ) + val settled = job?.isActive != true && aimed + if (!cameraWarm && ((now == AttachPhase.Menu && settled) || now == AttachPhase.Camera)) { + cameraWarm = true + } + if (now == AttachPhase.Menu && frozen != null) frozen = null + if (closing && now != AttachPhase.Collapsed) { + // Reopened while closing: take "+" back. + closing = false + handedBack = false + currentOnCovering() + } + if (now == AttachPhase.Collapsed && !closing) { + // Closing onto "+": give it back to the bar now and stop drawing ours, so the two + // never stack. The glass thins to nothing as the surface reaches "+", so by the time + // the window goes it draws nothing there and its removal cannot flicker. + closing = true + handedBack = true + currentOnUncovering() + } + if (now == AttachPhase.Collapsed && settled) { + currentOnGone() + return@LaunchedEffect + } + if (now == AttachPhase.Landing && landingSince == 0L) landingSince = System.nanoTime() + // A chip that never shows (scrolled away, say) must not hold the surface up. + val stale = landingSince != 0L && System.nanoTime() - landingSince > 800_000_000L + if (now == AttachPhase.Landing && settled && (chip != null || stale) && !fading) { + // On the chip: fade out, so the chip underneath takes over without a pop. + fading = true + job = launch { + fade.animateTo(0f, tween(120)) + currentOnGone() + } + } + + host.update( + rect = rect.value, + cornerPx = radius.value, + glass = attachGlassAmount(rect.value, plusRect, plusBlendPx), + blur = blurEnabled, + touchable = now == AttachPhase.Menu || (now == AttachPhase.Camera && !shot), + alpha = fade.value, + ) + // One frame after the surface first drew over "+", the bar's own "+" can hide. + if (framesShown < 2 && ++framesShown == 2) currentOnCovering() + if (fading && fade.value == 0f) return@LaunchedEffect + } + } +} + +@Composable +private fun SurfaceContent( + phase: AttachPhase, + rows: List, + rect: Animatable, + radius: Animatable, + fade: Animatable, + geometry: SurfaceGeometry, + ready: Boolean, + blur: Boolean, + cameraWarm: Boolean, + frozen: ImageBitmap?, + handedBack: Boolean, + plusBlendPx: Float, + onRowClick: (AttachRow) -> Unit, + onBack: () -> Unit, + onDismiss: () -> Unit, + onShutter: (preview: ImageBitmap?, uri: Uri) -> Unit, + onCaptured: (Uri) -> Unit, + onCameraError: (Throwable) -> Unit, +) { + if (!ready) return + val density = LocalDensity.current + var appeared by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { appeared = true } + + val menuShown = phase == AttachPhase.Menu + val cardShown = phase == AttachPhase.Camera || phase == AttachPhase.Landing + val plusAlpha by layerAlpha(!appeared || phase == AttachPhase.Collapsed) + val rowsAlpha by layerAlpha(menuShown) + val cardAlpha by layerAlpha(cardShown) + val rowsScale by animateFloatAsState( + if (menuShown) 1f else 0.85f, + tween(270, easing = LinearOutSlowInEasing), + label = "rowsScale", + ) + // Mounted only when the chat offers it, and then from the first frame, so the camera is + // already open when the menu morphs into it. + val hasCamera = AttachRow.Camera in rows + + Box( + modifier = Modifier + .fillMaxSize() + // Outside the surface: close it, and keep the tap off the transcript behind. + .pointerInput(Unit) { detectTapGestures { onDismiss() } }, + ) { + Box( + modifier = Modifier + .layout { measurable, constraints -> + val r = rect.value + val placeable = measurable.measure( + Constraints.fixed(r.width.roundToInt().coerceAtLeast(0), r.height.roundToInt().coerceAtLeast(0)), + ) + layout(constraints.maxWidth, constraints.maxHeight) { + placeable.place( + (r.left - geometry.windowOrigin.x).roundToInt(), + (r.top - geometry.windowOrigin.y).roundToInt(), + ) + } + } + .graphicsLayer { + shape = RoundedCornerShape(CornerSize(radius.value)) + clip = true + alpha = fade.value + } + .drawBehind { + val glass = attachGlassAmount(rect.value, geometry.plus, plusBlendPx) + // Without cross-window blur there is no backdrop window: the tint is drawn here. + if (!blur) drawRect(Tint.copy(alpha = SolidTintAlpha * glass)) + // "+"'s flat fill, which the glass takes over from as the surface grows. + if (!handedBack) drawRect(Color.White.copy(alpha = 0.10f * (1f - glass))) + } + .pointerInput(Unit) { detectTapGestures { } }, + ) { + // Each layer is laid out at its resting frame and moved by how far the clip has moved. + // A layer that is not shown stays composed but takes no touches. + @Composable + fun Layer( + resting: () -> Rect, + alpha: () -> Float, + interactive: Boolean = true, + scale: () -> Float = { 1f }, + content: @Composable () -> Unit, + ) { + val r0 = resting() + Box( + modifier = Modifier + .requiredSize(with(density) { r0.width.toDp() }, with(density) { r0.height.toDp() }) + .offset { + val r = rect.value + val rr = resting() + IntOffset((rr.left - r.left).roundToInt(), (rr.top - r.top).roundToInt()) + } + .graphicsLayer { + this.alpha = alpha() + val s = scale() + scaleX = s + scaleY = s + val rr = resting() + val p = geometry.plus + transformOrigin = TransformOrigin( + ((p.center.x - rr.left) / rr.width).coerceIn(0f, 1f), + ((p.center.y - rr.top) / rr.height).coerceIn(0f, 1f), + ) + } + .then(if (interactive) Modifier else Modifier.blockTouches()), + ) { content() } + } + + if (hasCamera && cameraWarm) { + // Under the shot, the live preview is skipped rather than drawn and covered: its + // frames would keep the clip and the GPU busy for as long as the card shrinks. + val shot = frozen != null + Layer(resting = { geometry.card }, alpha = { if (shot) 0f else cardAlpha }, interactive = cardShown) { + ChatCameraCard( + active = cardShown, + onBack = onBack, + onShutter = onShutter, + onCaptured = onCaptured, + onError = onCameraError, + ) + } + } + if (frozen != null && cardShown) { + // Fills the moving frame rather than a resting one, so the photo itself shrinks. + Image( + bitmap = frozen, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + if (rowsAlpha > 0.01f || menuShown) { + Layer(resting = { geometry.menu }, alpha = { rowsAlpha }, interactive = menuShown, scale = { rowsScale }) { + AttachMenuRows(rows = rows, onRowClick = onRowClick) + } + } + Layer(resting = { geometry.plus }, alpha = { if (handedBack) 0f else plusAlpha }) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), + imageVector = Icons.Outlined.Add, + contentDescription = null, + tint = CodeTheme.colors.textMain, + ) + } + } + } + } +} + +/** Takes every touch aimed at this subtree, before its children see it. */ +private fun Modifier.blockTouches(): Modifier = pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() } + } + } +} + +@Composable +private fun layerAlpha(shown: Boolean): androidx.compose.runtime.State = animateFloatAsState( + targetValue = if (shown) 1f else 0f, + // The arriving side fades in late, the leaving side out early and fast. + animationSpec = if (shown) tween(165, delayMillis = 135, easing = LinearOutSlowInEasing) + else tween(105, easing = LinearOutSlowInEasing), + label = "attachLayer", +) + +@Composable +private fun rememberCrossWindowBlurEnabled(view: View): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return false + val wm = remember(view) { view.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager } + var enabled by remember { mutableStateOf(wm.isCrossWindowBlurEnabled) } + DisposableEffect(wm) { + val listener = java.util.function.Consumer { enabled = it } + wm.addCrossWindowBlurEnabledListener(listener) + onDispose { wm.removeCrossWindowBlurEnabledListener(listener) } + } + return enabled +} + +/** The non-focusable windows the surface lives in: the content, and with blur a backdrop. */ +private class AttachWindow(private val anchor: View) { + private val background = GradientDrawable() + + // Under the content: follows the surface's frame, and carries the window blur and the tint. + private val backdrop = Dialog(anchor.context).apply { + requestWindowFeature(Window.FEATURE_NO_TITLE) + window?.apply { + setBackgroundDrawable(background) + setGravity(Gravity.TOP or Gravity.START) + setDimAmount(0f) + clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + // Not focusable: layered above the IME, and the field keeps focus. + addFlags( + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or + WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + ) + setWindowAnimations(0) + } + } + + // Full screen, transparent: draws the content and swallows outside taps, so they close the + // surface without also reaching the transcript (whose tap hides the keyboard). + private val content = Dialog(anchor.context, android.R.style.Theme_Translucent_NoTitleBar).apply { + requestWindowFeature(Window.FEATURE_NO_TITLE) + window?.apply { + setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT)) + setGravity(Gravity.TOP or Gravity.START) + setDimAmount(0f) + clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + addFlags( + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or + WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + ) + setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT) + setTitle("AttachSurface") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + attributes = attributes.apply { + layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS + } + } + setWindowAnimations(0) + } + } + private var touchable = true + private var backdropShown = false + + fun show(parent: CompositionContext, body: @Composable () -> Unit) { + val decor = content.window!!.decorView + decor.setViewTreeLifecycleOwner(anchor.findViewTreeLifecycleOwner()) + decor.setViewTreeViewModelStoreOwner(anchor.findViewTreeViewModelStoreOwner()) + decor.setViewTreeSavedStateRegistryOwner(anchor.findViewTreeSavedStateRegistryOwner()) + content.setContentView( + ComposeView(anchor.context).apply { + setParentCompositionContext(parent) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) + setContent(body) + }, + ) + // The backdrop is added first and kept, so it stays under the content: a window added + // later is stacked on top, and over the rows it dimmed them and let the composer through. + backdrop.show() + backdrop.window?.decorView?.visibility = View.INVISIBLE + content.show() + conceal() + } + + /** Shows the content window; the backdrop follows on the first [update] that wants it. */ + fun reveal() { + content.window?.decorView?.visibility = View.VISIBLE + } + + /** Hides both windows between opens, keeping the content window added. */ + fun conceal() { + content.window?.decorView?.visibility = View.INVISIBLE + setTouchable(false) + hideBackdrop() + } + + private fun hideBackdrop() { + if (!backdropShown) return + backdrop.window?.decorView?.visibility = View.INVISIBLE + backdropShown = false + } + + private fun setTouchable(touchable: Boolean) { + if (this.touchable == touchable) return + this.touchable = touchable + val flag = WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE + if (touchable) content.window?.clearFlags(flag) else content.window?.addFlags(flag) + } + + /** The content window's top-left on the screen. */ + fun contentOrigin(): Offset { + val loc = IntArray(2) + content.window?.decorView?.getLocationOnScreen(loc) + return Offset(loc[0].toFloat(), loc[1].toFloat()) + } + + /** The content window's size in pixels: the whole screen. */ + fun contentSize(): Size { + val decor = content.window?.decorView ?: return Size.Zero + return Size(decor.width.toFloat(), decor.height.toFloat()) + } + + fun keyboardUp(): Boolean { + val insets = ViewCompat.getRootWindowInsets(anchor) ?: return false + return insets.isVisible(WindowInsetsCompat.Type.ime()) + } + + /** How far the system bars reach up from the screen's bottom. */ + fun bottomSystemInsetPx(): Float { + val insets = ViewCompat.getRootWindowInsets(anchor) ?: return 0f + return insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom.toFloat() + } + + /** + * Only with cross-window blur does the backdrop window exist: it follows the surface's frame, + * which costs a window relayout per frame. Without blur the content window draws the tint, and + * nothing is relaid out while the surface moves. + */ + fun update(rect: Rect, cornerPx: Float, glass: Float, blur: Boolean, touchable: Boolean, alpha: Float) { + setTouchable(touchable) + val window = backdrop.window ?: return + if (!blur) { + hideBackdrop() + return + } + window.decorView.alpha = alpha + background.cornerRadius = cornerPx + // "+"'s flat fill is drawn by the content; the backdrop is the glass, fading in with it. + background.setColor(Tint.copy(alpha = GlassTintAlpha).toArgb()) + background.alpha = (glass.coerceIn(0f, 1f) * 255).roundToInt() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + window.setBackgroundBlurRadius((BlurRadiusPx * glass.coerceIn(0f, 1f)).roundToInt()) + } + val lp = window.attributes + val x = rect.left.roundToInt() + val y = rect.top.roundToInt() + val w = rect.width.roundToInt().coerceAtLeast(1) + val h = rect.height.roundToInt().coerceAtLeast(1) + if (lp.x != x || lp.y != y || lp.width != w || lp.height != h) { + lp.x = x + lp.y = y + lp.width = w + lp.height = h + window.attributes = lp + } + if (!backdropShown) { + // Placed before it shows, so it never flashes at the wrong spot. + window.decorView.visibility = View.VISIBLE + backdropShown = true + } + } + + fun dismiss() { + runCatching { content.dismiss() } + runCatching { backdrop.dismiss() } + } +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_AttachMenu() { + AttachMenu( + rows = attachRows(cashOffered = true, acceptsMedia = true, stagedCount = 0), + onRowClick = {}, + modifier = Modifier.padding(CodeTheme.dimens.staticGrid.x3), + ) +} + +private fun View.findActivity(): android.app.Activity? { + var c: Context? = context + while (c is android.content.ContextWrapper) { + if (c is android.app.Activity) return c + c = c.baseContext + } + return null +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatCameraPanel.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatCameraPanel.kt new file mode 100644 index 0000000000..080dd907b4 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatCameraPanel.kt @@ -0,0 +1,546 @@ +package com.flipcash.shared.chat.ui.media + +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import android.view.TextureView +import android.graphics.Canvas +import android.graphics.Bitmap +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import android.util.Range +import android.os.Process +import android.os.SystemClock +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import androidx.camera.view.PreviewView +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import com.flipcash.shared.chat.ui.ChatAnimations +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.FlashOff +import androidx.compose.material.icons.filled.FlashOn +import androidx.compose.material.icons.filled.FlipCameraAndroid +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.ui.draw.alpha +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.flipcash.app.core.android.IntentUtils +import com.flipcash.core.R +import com.getcode.theme.CodeTheme +import com.getcode.util.permissions.PermissionResult +import com.getcode.util.permissions.rememberCameraPermission +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.File +import java.util.UUID +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +internal const val CAMERA_SHUTTER_TAG = "chat_camera_shutter" +internal const val CAMERA_MORE_TAG = "chat_camera_more" + +/** The camera's measurements, from the theme's grid. */ +private object CameraDefaults { + val ShutterSize: Dp @Composable get() = CodeTheme.dimens.staticGrid.x14 + + /** The shutter's gap above the card's bottom edge. */ + val ShutterBottom: Dp @Composable get() = CodeTheme.dimens.staticGrid.x5 + + /** The back chevron and the "..." button's diameter. */ + val ControlSize: Dp @Composable get() = CodeTheme.dimens.staticGrid.x9 + + /** The flash and flip circles, a touch smaller than the chevron and "..." ones. */ + val SecondaryControlSize: Dp @Composable get() = CodeTheme.dimens.staticGrid.x8 + + /** The back chevron and the "..." button share the shutter's row: centred on it, this far from the sides. */ + val ControlInset: Dp @Composable get() = CodeTheme.dimens.staticGrid.x3 + val ControlBottom: Dp @Composable get() = ShutterBottom + (ShutterSize - ControlSize) / 2 + + val ControlSpacing: Dp @Composable get() = CodeTheme.dimens.staticGrid.x2 +} + +/** Which way the lens faces. The panel opens on [Back]. */ +enum class ChatCameraLens { Back, Front } + +/** + * A camera: a live preview with a white shutter, and a "…" button that unfolds the + * flash (on/off only, offered only when the lens has a flash) and flip controls. Opens on the back + * lens with the flash off. + * + * A capture is written as a JPEG under `cacheDir/chat_capture/` and handed to [onCaptured] as a + * file `Uri`; there is no review step, the host stages it. A failed capture goes to [onError]. + * [onShutter] gets the preview's last frame at the tap and the `Uri` the shot will be written to, + * for a host that animates the shot away and stages it before the file exists; [onCaptured] then + * gets that same `Uri` once it is written. + * + * What the panel shows depends on the camera permission: not yet asked, it asks (once [active]) and + * shows a spinner; denied, it explains and offers Settings; no camera on the device, "Camera + * unavailable". With access already granted it starts the camera as soon as it is composed, whether + * or not it is [active] (on screen), so a host can mount it hidden under a menu and have a live + * preview by the time the menu is opened into it. + */ +@Composable +fun ChatCameraPanel( + onCaptured: (Uri) -> Unit, + modifier: Modifier = Modifier, + active: Boolean = true, + onError: (Throwable) -> Unit = {}, + onShutter: (preview: ImageBitmap?, uri: Uri) -> Unit = { _, _ -> }, +) { + val context = LocalContext.current + val hasCamera = remember(context) { + context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) + } + val permission = rememberCameraPermission() + + LaunchedEffect(permission.status, active) { + if (active && hasCamera && permission.status == PermissionResult.NotRequested) permission.launch() + } + + Box(modifier = modifier.fillMaxSize().background(Color.Black)) { + when { + !hasCamera -> CameraMessage(stringResource(R.string.subtitle_chatCameraUnavailable)) + + permission.isGranted -> CameraPreview(active = active, onShutter = onShutter, onCaptured = onCaptured, onError = onError) + + permission.status == PermissionResult.NotRequested -> CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = Color.White, + ) + + else -> CameraMessage(stringResource(R.string.subtitle_chatCameraPermissionDenied)) { + Text( + text = stringResource(R.string.action_chatOpenSettings), + style = CodeTheme.typography.textMedium, + color = Color.Black, + modifier = Modifier + .clip(CircleShape) + .background(Color.White) + .clickable(role = Role.Button) { + context.startActivity(IntentUtils.appSettings(context)) + } + .padding(horizontal = CodeTheme.dimens.staticGrid.x4, vertical = CodeTheme.dimens.staticGrid.x2), + ) + } + } + } +} + +/** + * The camera with a back chevron to the attach menu, filling its container. [active] is whether it + * is on screen: only then does it ask for the camera permission. Photos need no card: the menu + * launches [rememberChatPhotoPicker] directly. + */ +@Composable +fun ChatCameraCard( + active: Boolean, + onBack: () -> Unit, + onCaptured: (Uri) -> Unit, + modifier: Modifier = Modifier, + onShutter: (preview: ImageBitmap?, uri: Uri) -> Unit = { _, _ -> }, + onError: (Throwable) -> Unit = {}, +) { + Box(modifier = modifier.fillMaxSize().background(Color.Black)) { + ChatCameraPanel(active = active, onCaptured = onCaptured, onError = onError, onShutter = onShutter) + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = CameraDefaults.ControlInset, bottom = CameraDefaults.ControlBottom) + .size(CameraDefaults.ControlSize) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.45f)) + .clickable(role = Role.Button, onClick = onBack), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = stringResource(R.string.description_chatAttachBack), + tint = Color.White, + ) + } + } +} + +@Composable +private fun androidx.compose.foundation.layout.BoxScope.CameraMessage( + text: String, + action: (@Composable () -> Unit)? = null, +) { + Column( + modifier = Modifier.align(Alignment.Center).padding(CodeTheme.dimens.staticGrid.x5), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x3), + ) { + Text( + text = text, + style = CodeTheme.typography.textMedium, + color = Color.White, + textAlign = TextAlign.Center, + ) + action?.invoke() + } +} + +@Composable +private fun CameraPreview( + active: Boolean, + onShutter: (preview: ImageBitmap?, uri: Uri) -> Unit, + onCaptured: (Uri) -> Unit, + onError: (Throwable) -> Unit, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + // COMPATIBLE (a TextureView) rather than PERFORMANCE (a SurfaceView): the preview lives inside a + // surface that fades, scales and clips to a rounded corner as it morphs, none of which a + // SurfaceView's separate window honours. + val previewView = remember { + PreviewView(context).apply { implementationMode = PreviewView.ImplementationMode.COMPATIBLE } + } + // A fixed 30fps floor: left to its default [5, 30] range the Seeker's HAL drops to 10fps in a + // dim room, which makes the preview stutter and slows auto-exposure, so it takes seconds to + // brighten after opening. + val preview = remember { Preview.Builder().setTargetFrameRate(PreviewFrameRate).build() } + val imageCapture = remember { + ImageCapture.Builder().setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY).build() + } + + var lens by remember { mutableStateOf(ChatCameraLens.Back) } + var flashOn by remember { mutableStateOf(false) } + var controlsOpen by remember { mutableStateOf(false) } + var camera by remember { mutableStateOf(null) } + var canFlip by remember { mutableStateOf(false) } + var capturing by remember { mutableStateOf(false) } + val shutterSource = remember { MutableInteractionSource() } + val shutterPressed by shutterSource.collectIsPressedAsState() + + // Mounted hidden, the panel opens collapsed to "..." every time it comes on screen, as on iOS. + LaunchedEffect(active) { if (!active) controlsOpen = false } + + val hasFlash = camera?.cameraInfo?.hasFlashUnit() == true + + LaunchedEffect(lens, lifecycleOwner) { + runCatching { + val provider = context.awaitCameraProvider() + canFlip = provider.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA) && + provider.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA) + preview.surfaceProvider = previewView.surfaceProvider + // Only this panel's use cases: unbindAll would also tear down anyone else's. + provider.unbind(preview, imageCapture) + camera = provider.bindToLifecycle(lifecycleOwner, lens.selector(), preview, imageCapture) + }.onFailure(onError) + } + // Mounted hidden under a menu, the camera must not outlive the panel: lifecycle binding alone + // would keep it open for as long as the host stays resumed. A shot still being written keeps it + // until the write ends: the host staged it at the shutter and may close the panel before then, + // and unbinding would abort the write. + val release = remember { CameraRelease { ProcessCameraProvider.getInstance(context).get().unbind(preview, imageCapture) } } + DisposableEffect(Unit) { + onDispose { release.panelGone() } + } + LaunchedEffect(flashOn, hasFlash) { + imageCapture.flashMode = + if (flashOn && hasFlash) ImageCapture.FLASH_MODE_ON else ImageCapture.FLASH_MODE_OFF + } + + Box(Modifier.fillMaxSize()) { + AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize()) + + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = CameraDefaults.ShutterBottom) + .size(CameraDefaults.ShutterSize) + .testTag(CAMERA_SHUTTER_TAG) + .alpha(if (shutterPressed) 0.6f else 1f) + .clip(CircleShape) + .border(BorderStroke(CodeTheme.dimens.thickBorder, Color.White), CircleShape) + .padding(CodeTheme.dimens.staticGrid.x1) + .clip(CircleShape) + .background(Color.White) + .clickable( + interactionSource = shutterSource, + indication = null, + enabled = !capturing, + role = Role.Button, + onClickLabel = stringResource(R.string.action_chatTakePhoto), + ) { + capturing = true + release.writing = true + val file = captureFile(context) + onShutter(previewView.thumbnail()?.asImageBitmap(), Uri.fromFile(file)) + imageCapture.capture( + context = context, + file = file, + onSaved = { capturing = false; release.written(); onCaptured(it) }, + onFailed = { capturing = false; release.written(); onError(it) }, + ) + }, + ) + + Column( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = CameraDefaults.ControlInset, bottom = CameraDefaults.ControlBottom), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Flash and flip unfold above the Β·Β·Β· as one column, growing from its bottom edge. + AnimatedVisibility(visible = controlsOpen, enter = StackEnter, exit = StackExit) { + Column( + modifier = Modifier.padding(bottom = CameraDefaults.ControlSpacing), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CameraDefaults.ControlSpacing), + ) { + if (hasFlash) { + CameraControl( + icon = if (flashOn) Icons.Filled.FlashOn else Icons.Filled.FlashOff, + description = stringResource( + if (flashOn) R.string.action_chatFlashOff else R.string.action_chatFlashOn, + ), + onClick = { flashOn = !flashOn }, + size = CameraDefaults.SecondaryControlSize, + ) + } + if (canFlip) { + CameraControl( + icon = Icons.Filled.FlipCameraAndroid, + description = stringResource( + if (lens == ChatCameraLens.Back) { + R.string.action_chatUseFrontCamera + } else { + R.string.action_chatUseBackCamera + }, + ), + size = CameraDefaults.SecondaryControlSize, + onClick = { + lens = if (lens == ChatCameraLens.Back) ChatCameraLens.Front else ChatCameraLens.Back + flashOn = false + }, + ) + } + } + } + Box { + CameraControl( + icon = if (controlsOpen) Icons.Filled.Close else Icons.Filled.MoreHoriz, + description = stringResource( + if (controlsOpen) R.string.action_chatHideCameraControls else R.string.action_chatMoreCameraControls, + ), + onClick = { controlsOpen = !controlsOpen }, + modifier = Modifier.testTag(CAMERA_MORE_TAG), + ) + // The Β·Β·Β· carries a dot while folded, the hint that more controls are behind it. + val dot by animateFloatAsState(if (controlsOpen) 0f else 1f, ChatAnimations.swap, label = "more dot") + Box( + Modifier + .align(Alignment.TopEnd) + .offset(x = -CodeTheme.dimens.staticGrid.x1 / 2, y = CodeTheme.dimens.staticGrid.x1 / 2) + .size(CodeTheme.dimens.staticGrid.x2) + .graphicsLayer { alpha = dot.coerceIn(0f, 1f) } + .background(MoreDotColor, CircleShape), + ) + } + } + } +} + +@Composable +private fun CameraControl( + icon: ImageVector, + description: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + size: Dp = CameraDefaults.ControlSize, +) { + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.45f)) + .clickable(role = Role.Button, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + // A changed glyph (Β·Β·Β· and βœ•, flash on and off) swaps in place rather than cutting. + AnimatedContent( + targetState = icon, + transitionSpec = { fadeIn(ChatAnimations.swap) togetherWith fadeOut(ChatAnimations.swap) }, + label = "camera control icon", + ) { glyph -> + Icon(imageVector = glyph, contentDescription = description, tint = Color.White) + } + } +} + +private val StackOrigin = TransformOrigin(0.5f, 1f) +private val StackEnter = scaleIn( + ChatAnimations.swap, + initialScale = ChatAnimations.reactionEnterScale, + transformOrigin = StackOrigin, +) + fadeIn(ChatAnimations.swap) +private val StackExit = scaleOut( + ChatAnimations.swap, + targetScale = ChatAnimations.reactionEnterScale, + transformOrigin = StackOrigin, +) + fadeOut(ChatAnimations.swap) + +// SwiftUI's Color.blue in dark mode. +private val MoreDotColor = Color(0xFF0A84FF) + +private fun ChatCameraLens.selector(): CameraSelector = when (this) { + ChatCameraLens.Back -> CameraSelector.DEFAULT_BACK_CAMERA + ChatCameraLens.Front -> CameraSelector.DEFAULT_FRONT_CAMERA +} + +/** + * The preview's current frame at a quarter of the view's size, as the user sees it. Only a + * stand-in that shrinks away, and [PreviewView.getBitmap] reads back the full frame and copies it + * again, which held the shutter tap's frame for ~80ms on the Seeker. + */ +private fun PreviewView.thumbnail(): Bitmap? { + val texture = getChildAt(0) as? TextureView ?: return bitmap + if (width == 0 || height == 0) return null + val w = (width / ThumbnailScale).coerceAtLeast(1) + val h = (height / ThumbnailScale).coerceAtLeast(1) + // Read at the texture's own aspect, a quarter size, so the stream isn't stretched first. + val frame = texture.getBitmap( + (texture.width / ThumbnailScale).coerceAtLeast(1), + (texture.height / ThumbnailScale).coerceAtLeast(1), + ) ?: return null + return Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888).also { out -> + Canvas(out).apply { + // CameraX rotates, scales and offsets the texture view with view properties (its + // matrix), not its texture transform; apply both at the reduced size, as drawn. + scale(1f / ThumbnailScale, 1f / ThumbnailScale) + translate(texture.left.toFloat(), texture.top.toFloat()) + concat(texture.matrix) + concat(texture.getTransform(null)) + drawBitmap(frame, null, android.graphics.Rect(0, 0, texture.width, texture.height), null) + } + frame.recycle() + } +} + +private const val ThumbnailScale = 4 + +/** Unbinds the camera once the panel is gone and no shot is being written, whichever comes last. */ +private class CameraRelease(private val unbind: () -> Unit) { + var writing = false + private var gone = false + + fun panelGone() { + gone = true + if (!writing) runCatching(unbind) + } + + fun written() { + writing = false + if (gone) runCatching(unbind) + } +} + +private fun captureDir(context: Context) = File(context.cacheDir, "chat_capture") + +private fun captureFile(context: Context): File { + val dir = captureDir(context).apply { mkdirs() } + return File(dir, "photo_${UUID.randomUUID()}.jpg") +} + +/** + * Deletes camera files a previous process left behind. A shot's file goes once its photo is + * encoded or dropped, but only while the process that took it lives: an install or a kill in + * between strands it. Files this process wrote are left alone, since a chat may still be encoding + * them, so this only takes ones older than the process. + */ +suspend fun deleteLeftoverCaptures(context: Context) = withContext(Dispatchers.IO) { + val processStart = System.currentTimeMillis() - + (SystemClock.elapsedRealtime() - Process.getStartElapsedRealtime()) + captureDir(context).listFiles() + ?.filter { it.lastModified() < processStart } + ?.forEach { it.delete() } +} + +private fun ImageCapture.capture( + context: Context, + file: File, + onSaved: (Uri) -> Unit, + onFailed: (Throwable) -> Unit, +) { + takePicture( + ImageCapture.OutputFileOptions.Builder(file).build(), + ContextCompat.getMainExecutor(context), + object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(output: ImageCapture.OutputFileResults) { + // The Uri given at the shutter, so the host can match the two. + onSaved(Uri.fromFile(file)) + } + + override fun onError(exception: ImageCaptureException) = onFailed(exception) + }, + ) +} + +private val PreviewFrameRate = Range(30, 30) + +private suspend fun Context.awaitCameraProvider(): ProcessCameraProvider = + suspendCancellableCoroutine { continuation -> + val future = ProcessCameraProvider.getInstance(this) + future.addListener( + { + runCatching { future.get() } + .onSuccess { continuation.resume(it) } + .onFailure { continuation.resumeWithException(it) } + }, + ContextCompat.getMainExecutor(this), + ) + } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaBubbleSizing.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaBubbleSizing.kt new file mode 100644 index 0000000000..69fa063b28 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaBubbleSizing.kt @@ -0,0 +1,35 @@ +package com.flipcash.shared.chat.ui.media + +/** The laid-out size of a photo bubble, in the unit [ChatMediaBubbleSizing.size] was given. */ +data class ChatMediaBubbleSize( + val width: Float, + val height: Float, + /** True when the photo's aspect ratio was clamped, so the center-crop drops part of it. */ + val cropped: Boolean, +) + +/** + * How tall a photo bubble is for a photo of a given shape. The same rule runs on iOS, pinned by the + * `bubble` cases of `test-vectors/chat_media.json`. + */ +object ChatMediaBubbleSizing { + /** The widest a photo may be relative to its height: height is never under half the width. */ + const val MIN_ASPECT = 0.5f + + /** The tallest a photo may be relative to its width: height is never over twice the width. */ + const val MAX_ASPECT = 2.0f + + /** + * The bubble is always [maxWidth] wide. Its height is `maxWidth Γ— clamp(h / w, 0.5, 2.0)`; a + * missing or zero dimension gives a square. [ChatMediaBubbleSize.cropped] says the clamp moved + * the ratio. + */ + fun size(imageWidth: Int?, imageHeight: Int?, maxWidth: Float): ChatMediaBubbleSize { + val w = imageWidth ?: 0 + val h = imageHeight ?: 0 + if (w <= 0 || h <= 0) return ChatMediaBubbleSize(maxWidth, maxWidth, cropped = false) + val ratio = h.toFloat() / w.toFloat() + val clamped = ratio.coerceIn(MIN_ASPECT, MAX_ASPECT) + return ChatMediaBubbleSize(maxWidth, maxWidth * clamped, cropped = clamped != ratio) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt new file mode 100644 index 0000000000..203878a055 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt @@ -0,0 +1,354 @@ +package com.flipcash.shared.chat.ui.media + +import com.getcode.theme.CodeTheme +import android.content.Context +import android.content.Intent +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculateCentroid +import androidx.compose.foundation.gestures.calculatePan +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.IosShare +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Constraints +import androidx.compose.foundation.shape.RoundedCornerShape +import kotlin.math.roundToInt +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChanged +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.core.content.FileProvider +import coil3.compose.AsyncImagePainter +import coil3.compose.rememberAsyncImagePainter +import com.flipcash.core.R +import kotlinx.coroutines.launch +import java.io.File +import kotlin.math.abs + +internal const val VIEWER_IMAGE_TAG = "chat_media_viewer_image" +internal const val VIEWER_CLOSE_TAG = "chat_media_viewer_close" +internal const val VIEWER_SHARE_TAG = "chat_media_viewer_share" + +private const val DISMISS_DISTANCE_FRACTION = 0.2f +private const val MIN_BACKGROUND_ALPHA = 0.2f + +/** + * A full-screen photo. + * + * Pinch zooms from 1Γ— to 4Γ— with the pan held inside the image's edges; a double tap goes to 2.5Γ— + * about the tapped point, or back to fit when already zoomed. A one-finger drag that starts while + * the image is at fit pulls it away; when zoomed the same drag pans instead. + * + * On its own the pull moves the image down, fading the black behind it, and past a fifth of the + * screen's height calls [onDismiss]. A host that draws the photo over something else, such as + * [ChatPhotoOverlay], takes the whole presentation instead: [frame] says where the photo sits and + * how opaque everything is, [onPull] and [onPullEnd] receive the drag, and [onDismiss] stays the + * close button's. + * + * [placeholder] (usually the bubble's BlurHash, or the thumbnail already on screen) shows until + * the full image has loaded, which [onFullImageLoaded] reports. Share stays disabled until + * [fullImageLoaded] is true, since there is nothing worth sharing before then. + * + * Hostable as a Nav3 entry: it takes plain parameters and calls back for close and share. The host + * owns what Share does; [shareImage] is the usual answer. + * + * @param model what Coil loads for the full image. + * @param frame the photo's rect, corners and the opacities, read at layout and draw time so a + * host can animate them without recomposing. Null fills the container. + * @param interactive false while the host is closing the viewer: touches and buttons do nothing. + */ +@Composable +fun ChatMediaViewer( + model: Any?, + fullImageLoaded: Boolean, + onFullImageLoaded: () -> Unit, + onShare: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + placeholder: Painter? = null, + zoomState: ChatMediaZoomState = remember { ChatMediaZoomState() }, + reduceMotion: Boolean = rememberReducedMotion(), + frame: (() -> ChatMediaFrame)? = null, + onPull: ((Offset) -> Unit)? = null, + onPullEnd: ((Offset) -> Unit)? = null, + interactive: Boolean = true, +) { + val scope = rememberCoroutineScope() + val dragY = remember { Animatable(0f) } + val currentOnDismiss by rememberUpdatedState(onDismiss) + val currentOnLoaded by rememberUpdatedState(onFullImageLoaded) + val currentInteractive by rememberUpdatedState(interactive) + val currentOnPull by rememberUpdatedState(onPull) + val currentOnPullEnd by rememberUpdatedState(onPullEnd) + val touchSlop = LocalViewConfiguration.current.touchSlop + + val painter = rememberAsyncImagePainter( + model = model, + onState = { state -> + if (state is AsyncImagePainter.State.Success) { + zoomState.content = state.painter.intrinsicSize + currentOnLoaded() + } + }, + ) + + Box( + modifier = modifier + .fillMaxSize() + .drawBehind { + val alpha = if (frame != null) { + frame().backdrop + } else if (size.height > 0f) { + // Fade the black out as the image is pulled away, never to nothing: the + // transcript should show through, but the photo should still read against it. + (1f - abs(dragY.value) / (size.height * 0.5f)).coerceIn(MIN_BACKGROUND_ALPHA, 1f) + } else { + 1f + } + drawRect(Color.Black, alpha = alpha) + } + .onSizeChanged { zoomState.container = Size(it.width.toFloat(), it.height.toFloat()) }, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .testTag(VIEWER_IMAGE_TAG) + .pointerInput(zoomState, reduceMotion) { + detectTapGestures( + onDoubleTap = { tap -> + if (currentInteractive) { + scope.launch { zoomState.toggle(tap, animated = !reduceMotion) } + } + }, + ) + } + .pointerInput(zoomState, touchSlop) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + if (!currentInteractive) { + do { + val event = awaitPointerEvent(PointerEventPass.Main) + event.changes.forEach { it.consume() } + } while (event.changes.any { it.pressed }) + return@awaitEachGesture + } + val tracker = VelocityTracker() + var pulling = false + var totalPan = Offset.Zero + do { + val event = awaitPointerEvent(PointerEventPass.Main) + val multiTouch = event.changes.count { it.pressed } > 1 + val zoom = event.calculateZoom() + val pan = event.calculatePan() + when { + multiTouch || zoomState.isZoomed -> { + zoomState.transform(event.calculateCentroid(), pan, zoom) + event.changes.forEach { if (it.positionChanged()) it.consume() } + } + + pulling -> { + event.changes.firstOrNull()?.let { tracker.addPosition(it.uptimeMillis, it.position) } + pullBy(pan, currentOnPull) { delta -> scope.launch { dragY.snapTo(dragY.value + delta.y) } } + event.changes.forEach { if (it.positionChanged()) it.consume() } + } + + else -> { + totalPan += pan + if (abs(totalPan.y) > touchSlop && abs(totalPan.y) > abs(totalPan.x)) { + pulling = true + event.changes.firstOrNull()?.let { tracker.addPosition(it.uptimeMillis, it.position) } + pullBy(totalPan, currentOnPull) { delta -> scope.launch { dragY.snapTo(delta.y) } } + event.changes.forEach { if (it.positionChanged()) it.consume() } + } + } + } + } while (event.changes.any { it.pressed }) + + // A pinch that ended below fit settles at fit. + if (zoomState.scale <= ChatMediaZoom.MIN_SCALE) zoomState.reset() + if (pulling) { + val end = currentOnPullEnd + if (end != null) { + val v = tracker.calculateVelocity() + end(Offset(v.x, v.y)) + } else { + val past = abs(dragY.value) > + zoomState.container.height * DISMISS_DISTANCE_FRACTION + if (past) { + currentOnDismiss() + } else { + scope.launch { + if (reduceMotion) dragY.snapTo(0f) else dragY.animateTo(0f, tween(200)) + } + } + } + } + } + }, + ) { + Box( + modifier = Modifier + .layout { measurable, constraints -> + // The photo's own frame inside the container, which a host animates; the + // whole container when it does not. + val rect = frame?.invoke()?.rect + val w = rect?.width?.roundToInt() ?: constraints.maxWidth + val h = rect?.height?.roundToInt() ?: constraints.maxHeight + val placeable = measurable.measure( + Constraints.fixed(w.coerceAtLeast(0), h.coerceAtLeast(0)), + ) + layout(constraints.maxWidth, constraints.maxHeight) { + placeable.place(rect?.left?.roundToInt() ?: 0, rect?.top?.roundToInt() ?: 0) + } + } + .graphicsLayer { + scaleX = zoomState.scale + scaleY = zoomState.scale + translationX = zoomState.offset.x + translationY = zoomState.offset.y + if (frame == null) dragY.value else 0f + if (frame != null) { + val f = frame() + alpha = f.contentAlpha + shape = RoundedCornerShape(f.radiusPx) + clip = f.radiusPx > 0f + } + }, + ) { + // Filled while a host frames the photo (its aspect eases to the photo's own by the + // time it is open); fitted when the viewer owns the whole screen. + val scale = if (frame != null) ContentScale.Crop else ContentScale.Fit + // The placeholder sits under the real image, which stays composed so the load + // runs, and which draws nothing until it has something. + if (!fullImageLoaded && placeholder != null) { + Image( + painter = placeholder, + contentDescription = null, + contentScale = scale, + modifier = Modifier.fillMaxSize(), + ) + } + Image( + painter = painter, + contentDescription = stringResource(R.string.label_chat_media_photo), + contentScale = scale, + modifier = Modifier.fillMaxSize(), + ) + } + } + + val chrome = Modifier.graphicsLayer { alpha = frame?.invoke()?.chromeAlpha ?: 1f } + ViewerButton( + icon = Icons.Filled.Close, + description = stringResource(R.string.description_chatMediaClose), + enabled = interactive, + tag = VIEWER_CLOSE_TAG, + onClick = onDismiss, + modifier = chrome.align(Alignment.TopStart), + ) + ViewerButton( + icon = Icons.Outlined.IosShare, + description = stringResource(R.string.description_chatMediaShare), + enabled = fullImageLoaded && interactive, + tag = VIEWER_SHARE_TAG, + onClick = onShare, + modifier = chrome.align(Alignment.TopEnd), + ) + } + + LaunchedEffect(model) { zoomState.reset() } +} + +/** Sends a pull to the host's [external] handler when it has one, else to the viewer's own [own]. */ +private fun pullBy(delta: Offset, external: ((Offset) -> Unit)?, own: (Offset) -> Unit) { + if (external != null) external(delta) else own(delta) +} + +@Composable +private fun ViewerButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + description: String, + enabled: Boolean, + tag: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + IconButton( + onClick = onClick, + enabled = enabled, + modifier = modifier + .statusBarsPadding() + .padding(CodeTheme.dimens.staticGrid.x2) + .size(CodeTheme.dimens.staticGrid.x8) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.4f)) + .testTag(tag), + ) { + Icon( + imageVector = icon, + contentDescription = description, + tint = Color.White.copy(alpha = if (enabled) 1f else 0.4f), + ) + } +} + +/** + * The directory the share sheet may read chat photos from. `res/xml/file_paths.xml` in the app + * exposes `cacheDir/chat_media/` through the existing `${applicationId}.fileprovider` authority; + * a file anywhere else makes [FileProvider] throw. + */ +fun chatMediaShareDir(context: Context): File = + File(context.cacheDir, "chat_media").apply { if (!exists()) mkdirs() } + +/** + * Opens the system share sheet for [file], a JPEG under [chatMediaShareDir]. Returns false, rather + * than throwing, when the file is outside the provider's paths or nothing can handle the intent. + */ +fun shareImage(context: Context, file: File, mimeType: String = "image/jpeg"): Boolean = runCatching { + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + val send = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + type = mimeType + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity( + Intent.createChooser(send, context.getString(R.string.title_chatMediaShare)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) +}.isSuccess diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomState.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomState.kt new file mode 100644 index 0000000000..e28e61bcbc --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomState.kt @@ -0,0 +1,133 @@ +package com.flipcash.shared.chat.ui.media + +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import kotlin.math.max + +/** The zoom arithmetic of the viewer, free of Compose state so it can be tested on the JVM. */ +internal object ChatMediaZoom { + const val MIN_SCALE = 1f + const val MAX_SCALE = 4f + const val DOUBLE_TAP_SCALE = 2.5f + + /** Scales under this count as "fit": the swipe-down dismiss is live. */ + private const val FIT_EPSILON = 0.01f + + fun isFit(scale: Float): Boolean = scale <= MIN_SCALE + FIT_EPSILON + + /** + * The size [content] occupies once fitted inside [container], preserving aspect. An unknown + * content size fits to the whole container. + */ + fun fittedSize(container: Size, content: Size): Size { + if (content.width <= 0f || content.height <= 0f || !content.width.isFinite() || !content.height.isFinite()) { + return container + } + val factor = minOf(container.width / content.width, container.height / content.height) + return Size(content.width * factor, content.height * factor) + } + + /** + * Limits [offset] so a [fitted] image scaled by [scale] and centered in [container] never + * leaves a gap between its edge and the container's. On an axis where the scaled image is + * smaller than the container, the image stays centered. + */ + fun clampOffset(offset: Offset, scale: Float, container: Size, fitted: Size): Offset { + val maxX = max(0f, (fitted.width * scale - container.width) / 2f) + val maxY = max(0f, (fitted.height * scale - container.height) / 2f) + return Offset(offset.x.coerceIn(-maxX, maxX), offset.y.coerceIn(-maxY, maxY)) + } + + /** + * The offset that keeps the image point under [focus] (container coordinates) where it is + * while the scale goes from [oldScale] to [newScale], then adds [pan]. + */ + fun offsetAfterZoom( + offset: Offset, + focus: Offset, + oldScale: Float, + newScale: Float, + pan: Offset, + container: Size, + ): Offset { + val center = Offset(container.width / 2f, container.height / 2f) + val ratio = newScale / oldScale + val fromCenter = focus - center + return fromCenter - (fromCenter - offset) * ratio + pan + } +} + +/** + * The viewer's zoom and pan. Hoisted so the host can reset it and tests can read it. + * + * [scale] runs 1 to 4. [offset] is the translation of the scaled image, in pixels, already clamped + * so the image's edges never pull inside the container. + */ +@Stable +class ChatMediaZoomState { + var scale by mutableFloatStateOf(1f) + private set + var offset by mutableStateOf(Offset.Zero) + private set + + /** Whether the image is magnified past fit. Swipe-down dismiss only applies when it is not. */ + val isZoomed: Boolean get() = !ChatMediaZoom.isFit(scale) + + internal var container: Size = Size.Zero + internal var content: Size = Size.Zero + + private val fitted: Size get() = ChatMediaZoom.fittedSize(container, content) + + /** One step of a pinch or pan: [zoom] is the factor since the last event, [pan] its movement. */ + internal fun transform(focus: Offset, pan: Offset, zoom: Float) { + val newScale = (scale * zoom).coerceIn(ChatMediaZoom.MIN_SCALE, ChatMediaZoom.MAX_SCALE) + val moved = ChatMediaZoom.offsetAfterZoom(offset, focus, scale, newScale, pan, container) + scale = newScale + offset = ChatMediaZoom.clampOffset(moved, newScale, container, fitted) + } + + /** Back to fit. */ + fun reset() { + scale = 1f + offset = Offset.Zero + } + + /** + * Double-tap: from fit, to [ChatMediaZoom.DOUBLE_TAP_SCALE] with [focus] held still; from any + * zoom, back to fit. Snaps when [animated] is false. + */ + internal suspend fun toggle(focus: Offset, animated: Boolean) { + val startScale = scale + val startOffset = offset + val zoomingIn = !isZoomed + val endScale = if (zoomingIn) ChatMediaZoom.DOUBLE_TAP_SCALE else 1f + val endOffset = if (zoomingIn) { + ChatMediaZoom.clampOffset( + ChatMediaZoom.offsetAfterZoom( + offset, focus, startScale, endScale, Offset.Zero, container, + ), + endScale, container, fitted, + ) + } else { + Offset.Zero + } + if (!animated) { + scale = endScale + offset = endOffset + return + } + animate(0f, 1f, animationSpec = tween(250)) { t, _ -> + scale = startScale + (endScale - startScale) * t + offset = startOffset + (endOffset - startOffset) * t + } + scale = endScale + offset = endOffset + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoBubble.kt new file mode 100644 index 0000000000..122a5836c6 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoBubble.kt @@ -0,0 +1,288 @@ +package com.flipcash.shared.chat.ui.media + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.material3.Text +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.core.R +import com.flipcash.shared.chat.ui.BubblePosition +import com.flipcash.shared.chat.ui.Bubble +import com.flipcash.shared.common.ui.rememberBlurHashPainter +import com.getcode.theme.CodeTheme + +internal const val PHOTO_BUBBLE_TAG = "chat_photo_bubble" +internal const val PHOTO_CAPTION_TAG = "chat_photo_caption" + +private const val FADE_IN_MILLIS = 250 + +/** + * A photo message. + * + * The photo fills a box [maxWidth] wide whose height follows [ChatMediaBubbleSizing]; the image is + * center-cropped. Layers, bottom to top: the BlurHash from [blurHash]; then, in this order of + * preference, [localModel] (the sender's own copy, shown at once) or [model] (the remote image, + * fading in over 250ms once it loads). [progress] is drawn on top, for the upload capsule. + * + * [blurhashOnly] shows the BlurHash and loads nothing β€” a redacted photo, or one in a chat the + * viewer has not joined β€” and is not clickable. [unavailable] does the same and says so in white + * over the BlurHash. Otherwise [onClick] fires on a tap. + * + * A [caption] is its own text bubble a grid step below, the two sharing a run so the facing corners + * are squared off. + * + * @param photoModifier applied to the photo box itself, which is what the overlay measures and hides. + * @param onLoadError reports why the remote [model] failed to load, so the host can decide it is [unavailable]. + * @param model what Coil loads for the remote image: a URL, a file, or an `ImageRequest`. + * @param localModel what Coil loads for a photo that is on this device already; wins over [model]. + * @param sentPreview a decoded copy of a photo this device sent. It replaces the BlurHash, so the + * photo is fully drawn from the first frame, and [model] or [localModel] swap in over it unfaded. + */ +@Composable +fun ChatPhotoBubble( + isFromSelf: Boolean, + maxWidth: Dp, + imageWidth: Int?, + imageHeight: Int?, + blurHash: String?, + modifier: Modifier = Modifier, + model: Any? = null, + localModel: Any? = null, + sentPreview: ImageBitmap? = null, + caption: String? = null, + position: BubblePosition = BubblePosition.Solo, + blurhashOnly: Boolean = false, + unavailable: Boolean = false, + progress: (@Composable BoxScope.() -> Unit)? = null, + photoModifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, + onLoadError: ((Throwable) -> Unit)? = null, +) { + val size = ChatMediaBubbleSizing.size(imageWidth, imageHeight, maxWidth.value) + val hasCaption = !caption.isNullOrEmpty() + val tappable = !blurhashOnly && !unavailable && onClick != null + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1), + horizontalAlignment = if (isFromSelf) Alignment.End else Alignment.Start, + ) { + Bubble( + isFromSelf = isFromSelf, + position = if (hasCaption) position.withCaptionBelow() else position, + maxWidth = maxWidth, + horizontalPadding = CodeTheme.dimens.none, + verticalPadding = CodeTheme.dimens.none, + bare = true, + onClick = if (tappable) onClick else null, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + ) { + val photoDescription = stringResource(R.string.label_chat_media_photo) + Box( + modifier = Modifier + .size(size.width.dp, size.height.dp) + .then(photoModifier) + .testTag(PHOTO_BUBBLE_TAG) + .semantics { contentDescription = photoDescription }, + ) { + PhotoLayers( + blurHash = blurHash, + model = model, + localModel = localModel, + sentPreview = sentPreview, + blurhashOnly = blurhashOnly || unavailable, + onLoadError = onLoadError, + ) + if (unavailable) { + Text( + text = stringResource(R.string.label_chat_media_unavailable), + style = CodeTheme.typography.textSmall, + fontWeight = FontWeight.Medium, + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = CodeTheme.dimens.staticGrid.x3), + ) + } else if (progress != null) { + progress() + } + } + } + + if (hasCaption) { + Bubble( + isFromSelf = isFromSelf, + position = position.withPhotoAbove(), + maxWidth = maxWidth, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + ) { + Text( + text = caption.orEmpty(), + style = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.Medium), + color = CodeTheme.colors.textMain, + modifier = Modifier.testTag(PHOTO_CAPTION_TAG), + ) + } + } + } +} + +/** The photo's place in a run once a caption follows it: the caption now closes the run. */ +internal fun BubblePosition.withCaptionBelow(): BubblePosition = when (this) { + BubblePosition.Solo -> BubblePosition.First + BubblePosition.Last -> BubblePosition.Middle + else -> this +} + +/** The caption's place in a run: the photo above it is part of the run. */ +internal fun BubblePosition.withPhotoAbove(): BubblePosition = when (this) { + BubblePosition.Solo -> BubblePosition.Last + BubblePosition.First -> BubblePosition.Middle + else -> this +} + +@Composable +private fun PhotoLayers( + blurHash: String?, + model: Any?, + localModel: Any?, + sentPreview: ImageBitmap?, + blurhashOnly: Boolean, + onLoadError: ((Throwable) -> Unit)?, +) { + val blur = rememberBlurHashPainter(blurHash) + val reduceMotion = rememberReducedMotion() + + Box(Modifier.fillMaxSize().background(CodeTheme.colors.surfaceVariant)) { + if (sentPreview != null && !blurhashOnly) { + Image( + bitmap = sentPreview, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } else if (blur != null && (blurhashOnly || localModel == null)) { + Image( + painter = blur, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + if (blurhashOnly) return@Box + + when { + localModel != null -> AsyncImage( + model = localModel, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + + model != null -> { + var loaded by remember(model) { mutableStateOf(false) } + // Our own photo replaces the same picture, so it is swapped in, not faded. + val alpha by animateFloatAsState( + targetValue = if (loaded) 1f else 0f, + animationSpec = if (reduceMotion || sentPreview != null) snap() else tween(FADE_IN_MILLIS), + label = "photoFadeIn", + ) + AsyncImage( + model = model, + contentDescription = null, + contentScale = ContentScale.Crop, + onState = { + loaded = it is AsyncImagePainter.State.Success + if (it is AsyncImagePainter.State.Error) onLoadError?.invoke(it.result.throwable) + }, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { this.alpha = alpha }, + ) + } + } + } +} + +// A valid 4x3 BlurHash used only by previews. +private const val PREVIEW_HASH = "LEHV6nWB2yk8pyo0adR*.7kCMdnj" + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_PhotoBubble_Placeholder() { + ChatPhotoBubble( + isFromSelf = true, + maxWidth = CodeTheme.dimens.staticGrid.x20 * 2.5f, + imageWidth = 4032, + imageHeight = 3024, + blurHash = PREVIEW_HASH, + progress = { ChatPhotoProgressOverlay(ChatPhotoPhase.Uploading(0.4f)) }, + ) +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_PhotoBubble_Caption() { + ChatPhotoBubble( + isFromSelf = false, + maxWidth = CodeTheme.dimens.staticGrid.x20 * 2.5f, + imageWidth = 3024, + imageHeight = 4032, + blurHash = PREVIEW_HASH, + caption = "from today", + blurhashOnly = true, + ) +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_PhotoBubble_Unavailable() { + ChatPhotoBubble( + isFromSelf = false, + maxWidth = CodeTheme.dimens.staticGrid.x20 * 2.5f, + imageWidth = 1000, + imageHeight = 1000, + blurHash = PREVIEW_HASH, + unavailable = true, + ) +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt new file mode 100644 index 0000000000..f7793deb8e --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt @@ -0,0 +1,222 @@ +package com.flipcash.shared.chat.ui.media + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import coil3.request.ImageRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch + +// The attach card's spring (iOS `attachCard`, 0.30s, no bounce): iOS's zoom transition does not +// overshoot either, and a photo that does would carry past the screen. +private const val DAMPING = 1f +private const val STIFFNESS = 439f + +/** + * The open, drag and close of the photo overlay, as one progress and one pull. + * + * [progress] is 0 with the photo on its bubble and 1 with it open. The frame lerps between the + * bubble's rect, looked up live so it can move, and an anchor: the fitted rect, shrunk and moved by + * the pull while the finger is down. Closing freezes the anchor where the finger left it and runs + * [progress] back to 0, so it is one spring in either direction and is never retargeted per frame. + */ +@Stable +internal class ChatPhotoPresentation( + private val scope: CoroutineScope, + private val lookup: () -> Rect?, + private val sourceRadiusPx: Float, + private val content: () -> Size, + private val density: Float, + private val reduceMotion: Boolean, + private val onClosed: () -> Unit, +) { + /** The overlay's size, and where its origin sits in the root the bubbles report in. */ + var container by mutableStateOf(Size.Zero) + var origin: Offset = Offset.Zero + + var closing by mutableStateOf(false) + private set + + private val progress = Animatable(0f) + private val pull = Animatable(Offset.Zero, Offset.VectorConverter) + private var frozen: Frozen? by mutableStateOf(null) + private var hasSource = true + private var lastSource: Rect? = null + + private class Frozen(val rect: Rect, val pull: Float) + + init { + hasSource = readSource() != null + } + + private fun readSource(): Rect? { + val bounds = lookup()?.translate(-origin) ?: return null + val usable = container == Size.Zero || ChatPhotoTransition.sourceUsable(bounds, container) + return if (usable) bounds.also { lastSource = it } else null + } + + fun frame(): ChatMediaFrame { + val size = container + if (size == Size.Zero) return ChatMediaFrame.Hidden + val source = if (hasSource) readSource() ?: lastSource else null + val open = ChatPhotoTransition.fittedRect(size, content()) + val held = frozen + val anchor: Rect + val t: Float + if (held != null) { + anchor = held.rect + t = held.pull + } else { + val offset = pull.value + t = ChatPhotoTransition.dragProgress(offset, size.height) + anchor = ChatPhotoTransition.draggedRect(open, offset, t) + } + return ChatPhotoTransition.frame(source, sourceRadiusPx, anchor, t, progress.value) + } + + suspend fun open() { + if (reduceMotion) progress.snapTo(1f) else progress.animateTo(1f, spring(DAMPING, STIFFNESS, 0.001f)) + } + + fun onPull(delta: Offset) { + if (closing) return + scope.launch(start = CoroutineStart.UNDISPATCHED) { pull.snapTo(pull.value + delta) } + } + + /** Called when the finger lifts after a pull, with its [velocity] in px per second. */ + fun onPullEnd(velocity: Offset) { + if (closing) return + if (ChatPhotoTransition.shouldDismiss(pull.value, velocity, container.height, density)) { + dismiss() + } else { + scope.launch { + if (reduceMotion) pull.snapTo(Offset.Zero) else pull.animateTo(Offset.Zero, spring(DAMPING, STIFFNESS)) + } + } + } + + /** Animates the photo back into its bubble, looked up again now, then reports [onClosed]. */ + fun dismiss() { + if (closing) return + val size = container + val open = ChatPhotoTransition.fittedRect(size, content()) + val offset = pull.value + val t = ChatPhotoTransition.dragProgress(offset, size.height) + frozen = Frozen(ChatPhotoTransition.draggedRect(open, offset, t), t) + hasSource = readSource() != null + closing = true + scope.launch { + if (reduceMotion) progress.snapTo(0f) else progress.animateTo(0f, spring(DAMPING, STIFFNESS, 0.001f)) + onClosed() + } + } +} + +/** + * The photo of message [messageId], opened over the chat: it grows out of its bubble in [sources], + * drags back into it, and goes when it lands. Compose it above the whole screen with the chat + * still live underneath, only while a photo is open. + * + * The caller hides the keyboard first, so the bubble the photo grows from is where it will rest. + */ +@Composable +fun ChatPhotoOverlay( + messageId: Long, + sources: ChatPhotoSources, + onClosed: () -> Unit, + modifier: Modifier = Modifier, + reduceMotion: Boolean = rememberReducedMotion(), +) { + val source = remember(messageId) { sources.sourceOf(messageId) } + if (source == null) { + LaunchedEffect(messageId) { onClosed() } + return + } + + val scope = rememberCoroutineScope() + val density = LocalDensity.current.density + val context = LocalContext.current + val currentOnClosed by rememberUpdatedState(onClosed) + val zoomState = remember(messageId) { ChatMediaZoomState() } + val windowSize = LocalWindowInfo.current.containerSize + + val presentation = remember(messageId) { + ChatPhotoPresentation( + scope = scope, + lookup = { source.boundsInRoot() }, + sourceRadiusPx = source.radiusPx, + content = { + source.imageSize + ?: zoomState.content.takeIf { it.width > 0f && it.height > 0f } + ?: Size.Zero + }, + density = density, + reduceMotion = reduceMotion, + onClosed = { currentOnClosed() }, + ).also { it.container = Size(windowSize.width.toFloat(), windowSize.height.toFloat()) } + } + + // Hidden in the composition that adds the overlay and shown in the one that removes it, so the + // bubble and the photo are never both drawn, and never both missing. + DisposableEffect(sources, messageId) { + sources.hiddenId = messageId + onDispose { if (sources.hiddenId == messageId) sources.hiddenId = null } + } + LaunchedEffect(presentation) { presentation.open() } + BackHandler { presentation.dismiss() } + + // The bubble already has the photo cached at its own size: draw that until the full one lands. + val request = remember(source) { + ImageRequest.Builder(context) + .data(source.model) + .placeholderMemoryCacheKey(source.model.cacheKey) + .build() + } + var fullImageLoaded by remember(messageId) { mutableStateOf(false) } + val share = rememberSharePhoto(source.model) + + Box( + modifier = modifier + .fillMaxSize() + .onSizeChanged { presentation.container = Size(it.width.toFloat(), it.height.toFloat()) } + .onGloballyPositioned { presentation.origin = it.positionInRoot() }, + ) { + ChatMediaViewer( + model = request, + fullImageLoaded = fullImageLoaded, + onFullImageLoaded = { fullImageLoaded = true }, + onShare = share, + onDismiss = presentation::dismiss, + zoomState = zoomState, + reduceMotion = reduceMotion, + frame = presentation::frame, + onPull = presentation::onPull, + onPullEnd = presentation::onPullEnd, + interactive = !presentation.closing, + ) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoPicker.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoPicker.kt new file mode 100644 index 0000000000..ab8a45e732 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoPicker.kt @@ -0,0 +1,46 @@ +package com.flipcash.shared.chat.ui.media + +import android.net.Uri +import android.provider.MediaStore +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState + +/** + * Launches the system photo picker, which presents as a sheet over the chat, for up to + * [remaining] images. The attach menu's Photos row calls the returned function directly; there + * is no in-panel step. + * + * Uses `PickVisualMedia` when one slot is left, since `PickMultipleVisualMedia` rejects a max + * below 2. Picked content URIs go to [onPicked] in selection order; a cancelled pick reports + * nothing. + */ +@Composable +fun rememberChatPhotoPicker( + remaining: Int, + onPicked: (List) -> Unit, +): () -> Unit { + val currentOnPicked by rememberUpdatedState(onPicked) + val currentRemaining by rememberUpdatedState(remaining) + val imageOnly = remember { PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) } + val single = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri != null) currentOnPicked(listOf(uri)) + } + val max = remaining.coerceIn(2, maxOf(2, MediaStore.getPickImagesMaxLimit())) + val multiple = rememberLauncherForActivityResult( + ActivityResultContracts.PickMultipleVisualMedia(max), + ) { uris -> + if (uris.isNotEmpty()) currentOnPicked(uris.take(currentRemaining)) + } + return { + when { + currentRemaining <= 0 -> Unit + currentRemaining == 1 -> single.launch(imageOnly) + else -> multiple.launch(imageOnly) + } + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt new file mode 100644 index 0000000000..dd6e05b26c --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt @@ -0,0 +1,189 @@ +package com.flipcash.shared.chat.ui.media + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.core.R +import com.getcode.theme.CodeTheme + +/** Where an outgoing photo is in its trip, as the progress capsule on the bubble shows it. */ +sealed interface ChatPhotoPhase { + /** Encoding and picking constraints; nothing to show yet. */ + data object Preparing : ChatPhotoPhase + + /** Bytes going up. [fraction] is 0..1. */ + data class Uploading(val fraction: Float) : ChatPhotoPhase + + /** Uploaded; the server is processing the blob. */ + data object Processing : ChatPhotoPhase + + /** Blob ready; the message itself is going out. */ + data object Sending : ChatPhotoPhase + + /** The message is delivered. The capsule fades away. */ + data object Sent : ChatPhotoPhase + + /** The send failed. The capsule is gone at once; the row's failure affordance takes over. */ + data object Failed : ChatPhotoPhase +} + +internal const val PHOTO_PROGRESS_TAG = "chat_photo_progress" +internal const val PHOTO_PROGRESS_FILL_TAG = "chat_photo_progress_fill" + +private const val SEGMENT_FRACTION = 0.35f +private const val FRACTION_MILLIS = 200 +private const val SENT_FADE_MILLIS = 250 +private const val SLIDE_MILLIS = 1100 + +/** + * The capsule at the bottom-end of a photo bubble that shows an upload's progress. Fill the + * bubble's box with it; it positions itself. + * + * [ChatPhotoPhase.Preparing] is an empty track, [ChatPhotoPhase.Uploading] a fill that follows the + * byte fraction, [ChatPhotoPhase.Processing] and [ChatPhotoPhase.Sending] a segment 35% of the track + * sliding across it. [ChatPhotoPhase.Sent] fades the capsule out; [ChatPhotoPhase.Failed] removes it + * immediately. With [reduceMotion] nothing animates: the fill jumps, the indeterminate segment sits + * still and the fade is a cut. + */ +@Composable +fun ChatPhotoProgressOverlay( + phase: ChatPhotoPhase, + modifier: Modifier = Modifier, + reduceMotion: Boolean = rememberReducedMotion(), +) { + val sent = phase == ChatPhotoPhase.Sent + val alpha by animateFloatAsState( + targetValue = if (sent) 0f else 1f, + animationSpec = if (reduceMotion) snap() else tween(SENT_FADE_MILLIS), + label = "photoProgressAlpha", + ) + if (phase == ChatPhotoPhase.Failed || (sent && alpha == 0f)) return + + val description = stringResource(R.string.description_chatPhotoUploadProgress) + val grid = CodeTheme.dimens.staticGrid + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.BottomEnd) { + Box( + modifier = Modifier + .padding(grid.x2) + .size(grid.x13, grid.x1) + .alpha(alpha) + .shadow(CodeTheme.dimens.thickBorder, CircleShape) + .clip(CircleShape) + .background(CodeTheme.colors.background.copy(alpha = 0.35f)) + .testTag(PHOTO_PROGRESS_TAG) + .semantics { + contentDescription = description + if (phase is ChatPhotoPhase.Uploading) { + progressBarRangeInfo = + ProgressBarRangeInfo(phase.fraction.coerceIn(0f, 1f), 0f..1f) + } + }, + ) { + when (phase) { + is ChatPhotoPhase.Uploading -> { + val fraction by animateFloatAsState( + targetValue = phase.fraction.coerceIn(0f, 1f), + animationSpec = if (reduceMotion) snap() else tween(FRACTION_MILLIS), + label = "photoProgressFraction", + ) + Fill(widthFraction = { fraction }, offsetFraction = { 0f }) + } + + ChatPhotoPhase.Processing, ChatPhotoPhase.Sending -> + IndeterminateSegment(reduceMotion) + + // Hold a full bar while the capsule fades. + ChatPhotoPhase.Sent -> Fill(widthFraction = { 1f }, offsetFraction = { 0f }) + + ChatPhotoPhase.Preparing, ChatPhotoPhase.Failed -> Unit + } + } + } +} + +@Composable +private fun IndeterminateSegment(reduceMotion: Boolean) { + if (reduceMotion) { + Fill(widthFraction = { SEGMENT_FRACTION }, offsetFraction = { (1f - SEGMENT_FRACTION) / 2f }) + return + } + val transition = rememberInfiniteTransition(label = "photoProgressSlide") + // The segment enters past the leading edge and leaves past the trailing one. + val offset by transition.animateFloat( + initialValue = -SEGMENT_FRACTION, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(SLIDE_MILLIS, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Restart, + ), + label = "photoProgressOffset", + ) + Fill(widthFraction = { SEGMENT_FRACTION }, offsetFraction = { offset }) +} + +@Composable +private fun Fill(widthFraction: () -> Float, offsetFraction: () -> Float) { + Box( + modifier = Modifier + .testTag(PHOTO_PROGRESS_FILL_TAG) + .layout { measurable, constraints -> + // Read in layout so a moving segment re-lays-out without recomposing. + val width = (constraints.maxWidth * widthFraction()).toInt().coerceAtLeast(0) + val placeable = measurable.measure( + constraints.copy(minWidth = width, maxWidth = width), + ) + layout(constraints.maxWidth, constraints.maxHeight) { + placeable.place((constraints.maxWidth * offsetFraction()).toInt(), 0) + } + } + .fillMaxSize() + .background(CodeTheme.colors.brand), + ) +} + +@Preview(widthDp = 200, heightDp = 100) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_Overlay_Uploading() { + Box(Modifier.size(CodeTheme.dimens.staticGrid.x20 * 2, CodeTheme.dimens.staticGrid.x20).background(Color.DarkGray)) { + ChatPhotoProgressOverlay(ChatPhotoPhase.Uploading(0.6f)) + } +} + +@Preview(widthDp = 200, heightDp = 100) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_Overlay_Processing() { + Box(Modifier.size(CodeTheme.dimens.staticGrid.x20 * 2, CodeTheme.dimens.staticGrid.x20).background(Color.DarkGray)) { + ChatPhotoProgressOverlay(ChatPhotoPhase.Processing) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoShare.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoShare.kt new file mode 100644 index 0000000000..40ae7ba928 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoShare.kt @@ -0,0 +1,49 @@ +package com.flipcash.shared.chat.ui.media + +import android.graphics.Bitmap +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import coil3.SingletonImageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.toBitmap +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Returns a function that shares the photo [model] draws. The image is read back through Coil, which + * has just loaded it for the viewer and so answers from its cache, then written under + * [chatMediaShareDir] where the share sheet is allowed to read it. + */ +@Composable +fun rememberSharePhoto(model: Any?): () -> Unit { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val currentModel by rememberUpdatedState(model) + return share@{ + val data = currentModel ?: return@share + scope.launch { + val file = try { + withContext(Dispatchers.IO) { + val result = SingletonImageLoader.get(context) + .execute(ImageRequest.Builder(context).data(data).build()) + val bitmap = (result as? SuccessResult)?.image?.toBitmap() ?: return@withContext null + File(chatMediaShareDir(context), "photo_${System.currentTimeMillis()}.jpg").also { target -> + target.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 95, it) } + } + } + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + null + } + if (file != null) shareImage(context, file) + } + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSources.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSources.kt new file mode 100644 index 0000000000..dbd25b7e50 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSources.kt @@ -0,0 +1,94 @@ +package com.flipcash.shared.chat.ui.media + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import com.flipcash.shared.chat.media.ChatPhoto +import com.flipcash.shared.chat.ui.BubbleDefaults + +/** What the overlay needs of one on-screen photo bubble: where it is, and what it draws. */ +@Stable +class ChatPhotoSource internal constructor( + internal val model: ChatPhoto, + /** The photo's own size, for the shape it opens to. Null when the message did not say. */ + internal val imageSize: Size?, + internal val radiusPx: Float, +) { + internal var coordinates: LayoutCoordinates? = null + + /** The bubble's bounds in the root's coordinates, or null when it is not on screen. */ + internal fun boundsInRoot(): Rect? = coordinates?.takeIf { it.isAttached }?.boundsInRoot() +} + +/** + * The photo bubbles currently composed in the transcript, by message id. The overlay reads a + * bubble's bounds when it opens and again when it closes, since the row may have moved in between, + * and hides the bubble ([hiddenId]) while the photo is drawn over it. + */ +@Stable +class ChatPhotoSources { + private val sources = HashMap() + + /** The message whose bubble is covered by the overlay and drawn at zero alpha. */ + var hiddenId by mutableStateOf(null) + + internal fun register(id: Long, source: ChatPhotoSource) { + sources[id] = source + } + + internal fun unregister(id: Long, source: ChatPhotoSource) { + if (sources[id] === source) sources.remove(id) + } + + internal fun sourceOf(id: Long): ChatPhotoSource? = sources[id] +} + +val LocalChatPhotoSources = staticCompositionLocalOf { null } + +/** + * Registers this photo box with the [LocalChatPhotoSources] under [messageId] while it is + * composed, and draws it at zero alpha while the overlay covers it. Does nothing without a [model] + * or a registry. + */ +@Composable +internal fun Modifier.chatPhotoSource( + messageId: Long?, + model: ChatPhoto?, + imageWidth: Int?, + imageHeight: Int?, + radiusPx: Float, +): Modifier { + val sources = LocalChatPhotoSources.current + if (sources == null || messageId == null || model == null) return this + val source = remember(model, imageWidth, imageHeight, radiusPx) { + val size = if ((imageWidth ?: 0) > 0 && (imageHeight ?: 0) > 0) { + Size(imageWidth!!.toFloat(), imageHeight!!.toFloat()) + } else { + null + } + ChatPhotoSource(model, size, radiusPx) + } + DisposableEffect(sources, messageId, source) { + sources.register(messageId, source) + onDispose { sources.unregister(messageId, source) } + } + return this + .onGloballyPositioned { source.coordinates = it } + .graphicsLayer { alpha = if (sources.hiddenId == messageId) 0f else 1f } +} + +@Composable +internal fun bubblePhotoRadiusPx(): Float = with(LocalDensity.current) { BubbleDefaults.cornerLarge.toPx() } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransition.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransition.kt new file mode 100644 index 0000000000..a4472fbbd7 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransition.kt @@ -0,0 +1,128 @@ +package com.flipcash.shared.chat.ui.media + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.lerp +import androidx.compose.ui.util.lerp + +/** + * Where the photo is drawn at one instant of the overlay's open, drag and close, in the overlay's + * own coordinates. + * + * @property radiusPx corner radius of [rect]. + * @property contentAlpha opacity of the photo; below 1 only when there is no bubble to fly to or from. + * @property backdrop opacity of the black behind it. + * @property chromeAlpha opacity of the close and share buttons. + */ +data class ChatMediaFrame( + val rect: Rect, + val radiusPx: Float, + val contentAlpha: Float, + val backdrop: Float, + val chromeAlpha: Float, +) { + companion object { + /** Nothing drawn: the overlay has no size yet. */ + val Hidden = ChatMediaFrame(Rect.Zero, 0f, 0f, 0f, 0f) + } +} + +/** The pure parts of the photo overlay: the pull's geometry, the release decision, the rect blend. */ +internal object ChatPhotoTransition { + /** A pull of this fraction of the container's height takes the shrink and the fade to their ends. */ + const val DRAG_FULL_FRACTION = 0.5f + + /** The scale a photo reaches at the end of a pull. */ + const val MIN_DRAG_SCALE = 0.6f + + /** The backdrop's opacity at the end of a pull: nearly the chat again, the way iOS's zoom dismissal clears. */ + const val MIN_DRAG_BACKDROP = 0.1f + + /** Letting go past this fraction of the container's height dismisses. */ + const val DISMISS_DISTANCE_FRACTION = 0.15f + + /** Letting go while moving down faster than this, in dp per second, dismisses. */ + const val DISMISS_VELOCITY_DP = 900f + + /** With no bubble to land in, the photo shrinks to this much of its size as it fades. */ + const val DETACHED_SCALE = 0.85f + + /** How far into the pull the finger is, 0 to 1. */ + fun dragProgress(pull: Offset, containerHeight: Float): Float { + if (containerHeight <= 0f) return 0f + return (pull.getDistance() / (containerHeight * DRAG_FULL_FRACTION)).coerceIn(0f, 1f) + } + + fun dragScale(t: Float): Float = lerp(1f, MIN_DRAG_SCALE, t.coerceIn(0f, 1f)) + + fun dragBackdrop(t: Float): Float = lerp(1f, MIN_DRAG_BACKDROP, t.coerceIn(0f, 1f)) + + /** [open] shrunk about its center for a pull at progress [t], then moved with the finger by [pull]. */ + fun draggedRect(open: Rect, pull: Offset, t: Float): Rect = scaledAbout(open, dragScale(t)).translate(pull) + + fun scaledAbout(rect: Rect, scale: Float): Rect { + val size = Size(rect.width * scale, rect.height * scale) + return Rect(rect.center - Offset(size.width / 2f, size.height / 2f), size) + } + + /** The rect a photo of [content] occupies fitted inside [container] at its origin, centered. */ + fun fittedRect(container: Size, content: Size): Rect { + val size = ChatMediaZoom.fittedSize(container, content) + return Rect(Offset((container.width - size.width) / 2f, (container.height - size.height) / 2f), size) + } + + /** + * Whether letting go of a pull dismisses: it was dragged far enough, or flung downward. + * [velocity] is in px per second and [density] converts the dp threshold. + */ + fun shouldDismiss(pull: Offset, velocity: Offset, containerHeight: Float, density: Float): Boolean { + val far = pull.getDistance() > containerHeight * DISMISS_DISTANCE_FRACTION + val flung = velocity.y > DISMISS_VELOCITY_DP * density && pull.y > 0f + return far || flung + } + + /** Whether a bubble at [bounds] is on screen enough to fly to or from. */ + fun sourceUsable(bounds: Rect, container: Size): Boolean { + if (bounds.width <= 0f || bounds.height <= 0f) return false + val visible = bounds.intersect(Rect(Offset.Zero, container)) + return visible.width > 0f && visible.height > 0f + } + + /** + * The frame at [progress]: 0 is the photo sitting on [source], 1 is [anchor], the open or + * mid-pull rect. One progress drives both directions, so the target ([source] or [anchor]) may + * move while it runs. With no [source] the photo grows from, and shrinks to, its own center while fading. + * + * @param anchorPull how far into a pull [anchor] is; the corners round and the backdrop and buttons fade with it. + */ + fun frame( + source: Rect?, + sourceRadius: Float, + anchor: Rect, + anchorPull: Float, + progress: Float, + ): ChatMediaFrame { + val p = progress.coerceIn(0f, 1f) + val anchorRadius = if (source != null) sourceRadius * anchorPull else 0f + val backdrop = dragBackdrop(anchorPull) * p + val chrome = (1f - anchorPull) * p + return if (source != null) { + ChatMediaFrame( + rect = lerp(source, anchor, p), + radiusPx = lerp(sourceRadius, anchorRadius, p), + contentAlpha = 1f, + backdrop = backdrop, + chromeAlpha = chrome, + ) + } else { + ChatMediaFrame( + rect = lerp(scaledAbout(anchor, DETACHED_SCALE), anchor, p), + radiusPx = anchorRadius, + contentAlpha = p, + backdrop = backdrop, + chromeAlpha = chrome, + ) + } + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ComposerPhotoChips.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ComposerPhotoChips.kt new file mode 100644 index 0000000000..626b73d405 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ComposerPhotoChips.kt @@ -0,0 +1,255 @@ +package com.flipcash.shared.chat.ui.media + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.Dp +import coil3.compose.AsyncImage +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.core.R +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.chat.ChatInputDefaults + +/** Where a staged photo is on its way to being sendable. */ +sealed interface ComposerPhotoChipState { + data object Preparing : ComposerPhotoChipState + data object Uploading : ComposerPhotoChipState + data object Uploaded : ComposerPhotoChipState + + /** The upload failed. [retryable] says whether trying again can help. */ + data class Failed(val retryable: Boolean) : ComposerPhotoChipState +} + +/** A staged photo. [model] is whatever Coil loads for the thumbnail, typically a local `Uri`. */ +data class ComposerPhotoChip( + val id: String, + val model: Any?, + val state: ComposerPhotoChipState, +) + +/** What a chip wears over its thumbnail. */ +enum class ComposerPhotoBadge { None, Retry, Error } + +/** No badge while the photo is preparing, uploading or uploaded; otherwise by [Failed.retryable]. */ +fun composerPhotoBadge(state: ComposerPhotoChipState): ComposerPhotoBadge = when (state) { + ComposerPhotoChipState.Preparing, + ComposerPhotoChipState.Uploading, + ComposerPhotoChipState.Uploaded -> ComposerPhotoBadge.None + + is ComposerPhotoChipState.Failed -> + if (state.retryable) ComposerPhotoBadge.Retry else ComposerPhotoBadge.Error +} + +internal const val CHIP_TAG_PREFIX = "composer_photo_chip_" + +/** The staged-photo chip's measurements, from the grid. The attach surface lands a capture on a chip, so it reads them too. */ +internal object PhotoChipDefaults { + val Size: Dp @Composable get() = CodeTheme.dimens.staticGrid.x11 + val Corner: Dp @Composable get() = ChatInputDefaults.HeaderChipCorner + val Spacing: Dp @Composable get() = CodeTheme.dimens.staticGrid.x2 + + /** + * How far the first and last chips sit in from the strip's edges, and the width of the fade there + * (iOS `ComposerChipStrip.edgeInset`, passed as `leadingInset` = 14). The same number for both, so + * chips at rest sit just clear of the fade and only scrolled-off ones are faded. + */ + val EdgeInset: Dp @Composable get() = CodeTheme.dimens.staticGrid.x3 +} + +/** Which edges of the strip fade: only one with chips scrolled past it. */ +internal data class ChipEdgeFades(val start: Boolean, val end: Boolean) + +internal fun chipEdgeFades(canScrollBackward: Boolean, canScrollForward: Boolean) = + ChipEdgeFades(start = canScrollBackward, end = canScrollForward) + +/** + * The staged photos above or inside the composer's field: a horizontally scrolling row of + * thumbnails fading out at both edges. Each has a remove button at its top-end; a failed one is + * scrimmed and shows either a retry arrow (tap retries) or a warning that it can't be sent. A tap + * on a thumbnail itself does nothing. + */ +@Composable +fun ComposerPhotoChips( + chips: List, + onRemove: (id: String) -> Unit, + onRetry: (id: String) -> Unit, + modifier: Modifier = Modifier, + newestChipModifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + val edgeInset = PhotoChipDefaults.EdgeInset + LazyRow( + state = listState, + modifier = modifier + .fillMaxWidth() + // The mask needs its own layer to cut through just this row's pixels. + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val fades = chipEdgeFades(listState.canScrollBackward, listState.canScrollForward) + val edge = edgeInset.toPx().coerceAtMost(size.width / 2f) + if (fades.start) { + drawRect( + Brush.horizontalGradient( + colors = listOf(Color.Transparent, Color.Black), + startX = 0f, + endX = edge, + ), + size = Size(edge, size.height), + blendMode = BlendMode.DstIn, + ) + } + if (fades.end) { + drawRect( + Brush.horizontalGradient( + colors = listOf(Color.Black, Color.Transparent), + startX = size.width - edge, + endX = size.width, + ), + topLeft = Offset(size.width - edge, 0f), + size = Size(edge, size.height), + blendMode = BlendMode.DstIn, + ) + } + }, + contentPadding = PaddingValues(horizontal = edgeInset), + horizontalArrangement = Arrangement.spacedBy(PhotoChipDefaults.Spacing), + ) { + items(chips, key = { it.id }) { chip -> + PhotoChip( + chip = chip, + onRemove = { onRemove(chip.id) }, + onRetry = { onRetry(chip.id) }, + modifier = if (chip.id == chips.lastOrNull()?.id) newestChipModifier else Modifier, + ) + } + } +} + +@Composable +private fun PhotoChip( + chip: ComposerPhotoChip, + onRemove: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(PhotoChipDefaults.Corner) + val badge = composerPhotoBadge(chip.state) + val chipSize = PhotoChipDefaults.Size + val grid = CodeTheme.dimens.staticGrid + // The remove button sits inside the thumbnail's corner, as on iOS, so the chip is the chip-sized square. + Box(Modifier.testTag(CHIP_TAG_PREFIX + chip.id)) { + Box( + modifier = modifier + .size(chipSize) + .clip(shape) + .background(CodeTheme.colors.surfaceVariant), + ) { + AsyncImage( + model = chip.model, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(chipSize), + ) + if (badge != ComposerPhotoBadge.None) { + Box( + Modifier + .size(chipSize) + .background(Color.Black.copy(alpha = 0.5f)), + ) + } + when (badge) { + ComposerPhotoBadge.Retry -> { + val label = stringResource(R.string.description_chatPhotoRetry) + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = label, + tint = Color.White, + modifier = Modifier + .align(Alignment.Center) + .size(chipSize) + .clickable(role = Role.Button, onClick = onRetry) + .padding(grid.x3), + ) + } + + ComposerPhotoBadge.Error -> Icon( + imageVector = Icons.Filled.Warning, + contentDescription = stringResource(R.string.description_chatPhotoFailed), + tint = CodeTheme.colors.error, + modifier = Modifier + .align(Alignment.Center) + .size(grid.x6), + ) + + ComposerPhotoBadge.None -> Unit + } + } + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(grid.x1) + .size(grid.x4) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.7f)) + .clickable(role = Role.Button, onClick = onRemove), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.description_chatPhotoRemove), + tint = Color.White, + modifier = Modifier.size(grid.x2), + ) + } + } +} + +@Preview(widthDp = 320) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_Chips() { + ComposerPhotoChips( + chips = listOf( + ComposerPhotoChip("a", null, ComposerPhotoChipState.Uploaded), + ComposerPhotoChip("b", null, ComposerPhotoChipState.Uploading), + ComposerPhotoChip("c", null, ComposerPhotoChipState.Failed(retryable = true)), + ComposerPhotoChip("d", null, ComposerPhotoChipState.Failed(retryable = false)), + ), + onRemove = {}, + onRetry = {}, + ) +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt new file mode 100644 index 0000000000..d89184c688 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt @@ -0,0 +1,195 @@ +package com.flipcash.shared.chat.ui.media + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.unit.Dp +import com.getcode.theme.CodeTheme +import com.flipcash.services.chat.BlobOpenFailure +import com.flipcash.services.models.chat.MediaItemRendition +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.media.ChatPhoto +import com.flipcash.shared.chat.media.SentPhotoPreviews +import com.flipcash.shared.chat.media.ChatPhotoUnavailable +import com.flipcash.shared.chat.media.MediaSendProgress +import com.flipcash.shared.chat.models.ChatAction +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.models.LocalChatActionHandler +import com.flipcash.shared.chat.models.ReceiptStatus +import com.flipcash.shared.chat.ui.Bubble +import com.flipcash.shared.chat.ui.BubblePosition +import com.flipcash.shared.chat.ui.ChatQuotePanel +import com.getcode.utils.TraceType +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.shared.chat.models.PhotoMessageContext +import com.getcode.utils.trace + +/** + * Progress of the photo messages this device is sending, by pending client id hex. A getter, not a + * map, so a bubble reads only its own entry and a tick on one upload doesn't recompose the others. + */ +val LocalChatMediaProgress = staticCompositionLocalOf<() -> Map> { { emptyMap() } } + +/** The photo this row draws: the message's own, or the one a reply carries. */ +/** + * How a photo row in [chatId] draws and opens, or null when [this] holds no photo. Carries the real + * sender, own id included: a sealed photo's key roles depend on it. + */ +fun ChatMessage.photoContext(chatId: ChatId, previewing: Boolean): PhotoMessageContext? = + if (content.none { it.photoBody() != null }) null + else PhotoMessageContext( + chatId = chatId, + senderId = senderId, + sealed = encryption != null, + redacted = redacted, + previewing = previewing, + ) + +fun MessageContent.photoBody(): MessageContent.Media? = when (this) { + is MessageContent.Media -> this + is MessageContent.Reply -> content.filterIsInstance().firstOrNull() + else -> null +} + +/** Whether a tap on a photo opens the viewer: only one that is showing, delivered, and loaded. */ +internal fun canOpenPhoto(blurhashOnly: Boolean, failed: Boolean, unavailable: Boolean): Boolean = + !blurhashOnly && !failed && !unavailable + +/** + * Whether [error] means the photo can't be drawn. A key that has not arrived yet can still open + * later, so it leaves the BlurHash up instead. + */ +internal fun isPhotoUnavailable(error: Throwable): Boolean { + val unavailable = generateSequence(error) { it.cause }.filterIsInstance().firstOrNull() + return when (unavailable) { + null -> true // Coil could not decode what it was given. + is ChatPhotoUnavailable.Open -> unavailable.reason != BlobOpenFailure.KeyPending + else -> true + } +} + +internal fun MediaSendProgress.toPhase(): ChatPhotoPhase = when (this) { + MediaSendProgress.Preparing -> ChatPhotoPhase.Preparing + is MediaSendProgress.Uploading -> ChatPhotoPhase.Uploading(fraction) + MediaSendProgress.Processing -> ChatPhotoPhase.Processing + MediaSendProgress.Sending -> ChatPhotoPhase.Sending + MediaSendProgress.Sent -> ChatPhotoPhase.Sent + is MediaSendProgress.Failed -> ChatPhotoPhase.Failed +} + +/** + * A photo message in the transcript, with its citation above it when it is a reply and its caption + * under it. See [ChatPhotoBubble] for the drawing. + */ +@Composable +internal fun MediaMessageBubble( + item: ChatListItem.ContentBubble, + body: MessageContent.Media, + position: BubblePosition, + maxWidth: Dp, + modifier: Modifier = Modifier, + quote: ChatQuote? = null, + onQuoteClick: (() -> Unit)? = null, + interactive: Boolean = true, + onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, +) { + val actionHandler = LocalChatActionHandler.current + val rendition = body.items.firstOrNull()?.let { it.rendition(MediaItemRendition.Role.ORIGINAL) ?: it.renditions.firstOrNull() } + val image = rendition?.blob?.image + val info = item.photo + // The optimistic row has no blob id yet and the staged file as its URL. + val localUri = rendition?.takeIf { it.blobId.bytes.isEmpty() }?.blob?.downloadUrl?.takeIf { it.isNotEmpty() } + // A photo sent from this process: its decoded copy is drawn from the first frame, through the + // swap from the staged file to the stored blob, instead of the BlurHash. + val sentPreview = remember(item.pendingClientIdHex) { + item.pendingClientIdHex?.let(SentPhotoPreviews::forMessage)?.asImageBitmap() + } + val model = remember(rendition, info) { + if (rendition == null || info == null || localUri != null) null + else ChatPhoto(info.chatId, rendition, info.senderId, info.sealed, info.redacted) + } + + val progressOf = LocalChatMediaProgress.current + val progress by remember(item.pendingClientIdHex) { + derivedStateOf { item.pendingClientIdHex?.let { progressOf()[it] } } + } + val failed = item.receiptStatus == ReceiptStatus.FAILED || progress is MediaSendProgress.Failed + + var loadError by remember(model) { mutableStateOf(null) } + val unavailable = loadError?.let(::isPhotoUnavailable) == true + val blurhashOnly = info?.blurhashOnly == true + + val onClick: (() -> Unit)? = when { + !interactive -> null + failed -> ({ actionHandler(ChatAction.RetryMessage(item)) }) + canOpenPhoto(blurhashOnly, failed = false, unavailable = unavailable) && localUri == null -> + ({ actionHandler(ChatAction.OpenPhoto(item)) }) + else -> null + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1), + horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start, + ) { + if (quote != null) { + Bubble( + isFromSelf = item.isFromSelf, + position = position.withCaptionBelow(), + maxWidth = maxWidth, + onLongClick = onLongClick, + ) { + ChatQuotePanel( + quote = quote, + onClick = onQuoteClick, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + ) + } + } + ChatPhotoBubble( + isFromSelf = item.isFromSelf, + maxWidth = maxWidth, + imageWidth = image?.width, + imageHeight = image?.height, + blurHash = image?.blurhash?.takeIf { it.isNotEmpty() }, + model = model, + localModel = localUri, + sentPreview = sentPreview, + caption = body.caption?.text, + position = if (quote != null) position.withPhotoAbove() else position, + blurhashOnly = blurhashOnly, + unavailable = unavailable, + progress = progress?.let { p -> + { ChatPhotoProgressOverlay(p.toPhase()) } + }, + photoModifier = Modifier.chatPhotoSource( + // A photo this device is still sending has no model and cannot open. + messageId = item.messageId.takeIf { onClick != null && !failed }, + model = model, + imageWidth = image?.width, + imageHeight = image?.height, + radiusPx = bubblePhotoRadiusPx(), + ), + onClick = onClick, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + onLoadError = { + trace(tag = "MediaMessageBubble", message = "Photo failed to load: ${it.message}", type = TraceType.Log) + loadError = it + }, + ) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/PhotoQuoteThumbnail.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/PhotoQuoteThumbnail.kt new file mode 100644 index 0000000000..d4c5a895d6 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/PhotoQuoteThumbnail.kt @@ -0,0 +1,87 @@ +package com.flipcash.shared.chat.ui.media + +import com.flipcash.shared.chat.media.ChatPhoto +import com.flipcash.shared.chat.models.ChatQuoteSnippet +import androidx.compose.runtime.remember + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.getcode.theme.CodeTheme +import coil3.compose.AsyncImage +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.shared.common.ui.rememberBlurHashPainter + +internal const val PHOTO_QUOTE_THUMBNAIL_TAG = "photo_quote_thumbnail" + +/** + * The square beside a quoted photo's snippet, in the reply strip and inside a quoting bubble. + * BlurHash underneath, [model] over it once it loads. A [redacted] photo draws nothing at all: + * the snippet stands alone, so the quote does not leak what the photo shows. + */ +@Composable +fun PhotoQuoteThumbnail( + model: Any?, + blurHash: String?, + redacted: Boolean, + modifier: Modifier = Modifier, +) { + if (redacted) return + val blur = rememberBlurHashPainter(blurHash) + val size = CodeTheme.dimens.staticGrid.x8 + Box( + modifier = modifier + .size(size) + .clip(RoundedCornerShape(CodeTheme.dimens.staticGrid.x1)) + .testTag(PHOTO_QUOTE_THUMBNAIL_TAG), + ) { + if (blur != null) { + Image( + painter = blur, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(size), + ) + } + if (model != null) { + AsyncImage( + model = model, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(size), + ) + } + } +} + +/** [PhotoQuoteThumbnail] for a quoted photo, drawn from what the snippet carries. */ +@Composable +fun QuotedPhotoThumbnail(snippet: ChatQuoteSnippet.Photo, modifier: Modifier = Modifier) { + val rendition = snippet.rendition + val chatId = snippet.chatId + val model = remember(snippet) { + if (rendition == null || chatId == null || snippet.redacted) null + else ChatPhoto(chatId, rendition, snippet.senderId, snippet.sealed, snippet.redacted) + } + PhotoQuoteThumbnail( + model = model, + blurHash = rendition?.blob?.image?.blurhash, + redacted = snippet.redacted, + modifier = modifier, + ) +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_PhotoQuoteThumbnail() { + PhotoQuoteThumbnail(model = null, blurHash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj", redacted = false) +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ReducedMotion.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ReducedMotion.kt new file mode 100644 index 0000000000..89036bfbbf --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ReducedMotion.kt @@ -0,0 +1,23 @@ +package com.flipcash.shared.chat.ui.media + +import android.provider.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext + +/** + * True when the user has turned system animations off (`ANIMATOR_DURATION_SCALE` is 0). + * + * Read straight from [Settings.Global] rather than through `rememberAnimationScale`, whose + * `LocalSystemSettings` falls back to a stub where the host has not provided one. A missing or + * unreadable setting means animations are on. + */ +@Composable +internal fun rememberReducedMotion(): Boolean { + val resolver = LocalContext.current.contentResolver + return remember(resolver) { + runCatching { + Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f + }.getOrDefault(false) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt index 5e33fc8422..2ead5e583d 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt @@ -342,13 +342,29 @@ class ChatSummaryPreviewTest { } @Test - fun `a chat whose newest message has no preview still counts as having messages`() { - // Media previews as nothing today, which is not the same as an empty chat β€” the row must - // not label it "Nothing yet". - val reference = groupReference(listOf(MessageContent.Media(items = emptyList(), caption = null))) + fun `a photo previews as its caption behind the camera`() { + every { resources.getString(R.string.label_chat_media_photo) } returns "Photo" + val bare = MessageContent.Media(items = emptyList(), caption = null) + val captioned = MessageContent.Media(items = emptyList(), caption = MessageContent.Text("sunset")) - assertTrue(reference.hasMessages) - assertNull(reference.lastMessagePreview) + assertEquals("πŸ“· Photo", preview(listOf(bare))) + assertEquals("πŸ“· sunset", preview(listOf(captioned))) + } + + @Test + fun `a photo is prefixed like a typed message`() { + every { resources.getString(R.string.label_chat_media_photo) } returns "Photo" + every { resources.getString(R.string.label_chat_preview_sentMessage, "πŸ“· Photo") } returns "You: πŸ“· Photo" + val bare = MessageContent.Media(items = emptyList(), caption = null) + + every { + resources.getString(R.string.label_chat_preview_senderMessage, "Alice", "πŸ“· Photo") + } returns "Alice: πŸ“· Photo" + + assertEquals("You: πŸ“· Photo", preview(listOf(bare), senderId = self)) + val group = groupReference(listOf(bare)) + assertTrue(group.hasMessages) + assertEquals("Alice: πŸ“· Photo", group.lastMessagePreview) } @Test diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/AttachCardGeometryTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/AttachCardGeometryTest.kt new file mode 100644 index 0000000000..e0fc27a423 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/AttachCardGeometryTest.kt @@ -0,0 +1,63 @@ +package com.flipcash.shared.chat.ui.media + +import kotlin.test.Test +import kotlin.test.assertEquals + +class AttachCardGeometryTest { + @Test + fun `card is 58 percent of the screen, rounded`() { + assertEquals(1393f, cardHeight(screenHeightPx = 2402f, fallbackPx = 480f)) + } + + @Test + fun `an unmeasured screen falls back`() { + assertEquals(480f, cardHeight(screenHeightPx = 0f, fallbackPx = 480f)) + } + + @Test + fun `card stands inset from the sides and above the bottom inset`() { + val rect = cardRect(width = 1200f, height = 2400f, insetPx = 30f, bottomInsetPx = 80f, heightPx = 1392f) + assertEquals(30f, rect.left) + assertEquals(1170f, rect.right) + assertEquals(2320f, rect.bottom) + assertEquals(928f, rect.top) + } + + @Test + fun `card never grows taller than the room above the bottom inset`() { + val rect = cardRect(width = 1200f, height = 400f, insetPx = 30f, bottomInsetPx = 80f, heightPx = 1000f) + assertEquals(0f, rect.top) + assertEquals(320f, rect.bottom) + } + + // Seeker: 1200 x 2670, 12dp = 36px, nav bar 72px. The card does not depend on the keyboard. + @Test + fun `card spans the full width with equal insets and covers the composer`() { + val rect = cardRect(width = 1200f, height = 2670f, insetPx = 24f, bottomInsetPx = 24f + 72f, heightPx = cardHeight(2670f, 480f)) + assertEquals(24f, rect.left) + assertEquals(1200f - 24f, rect.right) + assertEquals(2670f - 96f, rect.bottom) + assertEquals(1549f, rect.height) + } + + @Test + fun `menu above the bottom is left alone`() { + val menu = androidx.compose.ui.geometry.Rect(39f, 1400f, 699f, 1830f) + assertEquals(menu, menuClampedAbove(menu, limitBottom = 2526f)) + } + + @Test + fun `menu is lifted onto the screen when the keyboard is down`() { + val menu = androidx.compose.ui.geometry.Rect(39f, 2330f, 699f, 2760f) + val lifted = menuClampedAbove(menu, limitBottom = 2526f) + assertEquals(2526f, lifted.bottom) + assertEquals(2096f, lifted.top) + assertEquals(menu.width, lifted.width) + } + + @Test + fun `menu limit is the composer bottom with the keyboard down and the screen with it up`() { + assertEquals(1700f, menuLimitBottom(screenBottom = 2526f, composerBottom = 1700f, keyboardUp = false)) + assertEquals(2526f, menuLimitBottom(screenBottom = 2526f, composerBottom = 1700f, keyboardUp = true)) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/AttachRowsTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/AttachRowsTest.kt new file mode 100644 index 0000000000..71f8356763 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/AttachRowsTest.kt @@ -0,0 +1,45 @@ +package com.flipcash.shared.chat.ui.media + +import kotlin.test.Test +import kotlin.test.assertEquals + +class AttachRowsTest { + + @Test + fun allRowsInOrder() { + assertEquals( + listOf(AttachRow.Cash, AttachRow.Camera, AttachRow.Photos), + attachRows(cashOffered = true, acceptsMedia = true, stagedCount = 0), + ) + } + + @Test + fun mediaRowsHiddenWithoutMedia() { + assertEquals( + listOf(AttachRow.Cash), + attachRows(cashOffered = true, acceptsMedia = false, stagedCount = 0), + ) + } + + @Test + fun cashRowHiddenWithoutCash() { + assertEquals( + listOf(AttachRow.Camera, AttachRow.Photos), + attachRows(cashOffered = false, acceptsMedia = true, stagedCount = 3), + ) + } + + @Test + fun noRows() { + assertEquals(emptyList(), attachRows(cashOffered = false, acceptsMedia = false, stagedCount = 0)) + } + + @Test + fun mediaRowsHiddenAtLimit() { + assertEquals( + listOf(AttachRow.Cash), + attachRows(cashOffered = true, acceptsMedia = true, stagedCount = 10), + ) + } + +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaBubbleSizingTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaBubbleSizingTest.kt new file mode 100644 index 0000000000..59c4a92227 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaBubbleSizingTest.kt @@ -0,0 +1,56 @@ +package com.flipcash.shared.chat.ui.media + +import org.json.JSONObject +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The `bubble` half of `test-vectors/chat_media.json`. The canonical copy lives in the orchestrator + * repo; this one is synced, never edited locally. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class ChatMediaBubbleSizingTest { + + private val root: JSONObject by lazy { + val json = javaClass.classLoader!! + .getResourceAsStream("chat_media.json")!! + .bufferedReader().use { it.readText() } + JSONObject(json) + } + + @Test + fun `aspect limits match the fixture`() { + assertEquals(root.getDouble("minAspect").toFloat(), ChatMediaBubbleSizing.MIN_ASPECT) + assertEquals(root.getDouble("maxAspect").toFloat(), ChatMediaBubbleSizing.MAX_ASPECT) + } + + @Test + fun `every bubble case matches the fixture`() { + val cases = root.getJSONArray("bubble") + assertTrue(cases.length() > 0) + for (i in 0 until cases.length()) { + val case = cases.getJSONObject(i) + val name = case.getString("name") + val expected = case.getJSONObject("expected") + val actual = ChatMediaBubbleSizing.size( + imageWidth = case.getInt("imageWidth"), + imageHeight = case.getInt("imageHeight"), + maxWidth = case.getDouble("maxWidth").toFloat(), + ) + assertEquals(expected.getDouble("width").toFloat(), actual.width, 0.001f, "$name width") + assertEquals(expected.getDouble("height").toFloat(), actual.height, 0.001f, "$name height") + assertEquals(expected.getBoolean("cropped"), actual.cropped, "$name cropped") + } + } + + @Test + fun `null dimensions give a square`() { + val size = ChatMediaBubbleSizing.size(null, null, 240f) + assertEquals(ChatMediaBubbleSize(240f, 240f, cropped = false), size) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomTest.kt new file mode 100644 index 0000000000..bb0f7af534 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomTest.kt @@ -0,0 +1,52 @@ +package com.flipcash.shared.chat.ui.media + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ChatMediaZoomTest { + + private val container = Size(400f, 800f) + + @Test + fun fitIsNotZoomed() { + assertTrue(ChatMediaZoom.isFit(1f)) + assertFalse(ChatMediaZoom.isFit(2.5f)) + } + + @Test + fun fittedSizePreservesAspect() { + assertEquals(Size(400f, 200f), ChatMediaZoom.fittedSize(container, Size(800f, 400f))) + assertEquals(container, ChatMediaZoom.fittedSize(container, Size.Zero)) + } + + @Test + fun panStaysInsideTheImageEdges() { + val fitted = Size(400f, 200f) + // At 4x the image is 1600x800: 600 of horizontal slack each way, none vertically. + val clamped = ChatMediaZoom.clampOffset(Offset(5_000f, 5_000f), 4f, container, fitted) + assertEquals(Offset(600f, 0f), clamped) + } + + @Test + fun zoomHoldsThePointUnderTheFocus() { + val focus = Offset(300f, 500f) + val offset = ChatMediaZoom.offsetAfterZoom(Offset.Zero, focus, 1f, 2f, Offset.Zero, container) + val center = Offset(200f, 400f) + // The image point under the focus before is (focus - center); after, it sits at + // (that * scale) + offset and must land back on the focus. + assertEquals(focus - center, (focus - center) * 2f + offset) + } + + @Test + fun transformClampsScale() { + val state = ChatMediaZoomState().apply { container = this@ChatMediaZoomTest.container } + state.transform(Offset(200f, 400f), Offset.Zero, 10f) + assertEquals(4f, state.scale) + state.transform(Offset(200f, 400f), Offset.Zero, 0.01f) + assertEquals(1f, state.scale) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt new file mode 100644 index 0000000000..9df5298877 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt @@ -0,0 +1,222 @@ +package com.flipcash.shared.chat.ui.media + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.doubleClick +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.dp +import com.getcode.theme.DesignSystem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +class ChatPhotoMediaUiTest { + + @get:Rule + val rule = createComposeRule() + + // region progress overlay + + private fun setOverlay(phase: ChatPhotoPhase) = rule.setContent { + DesignSystem { + Box(Modifier.size(200.dp)) { ChatPhotoProgressOverlay(phase, reduceMotion = true) } + } + } + + @Test + fun overlayShowsWhileUploading() { + setOverlay(ChatPhotoPhase.Uploading(0.4f)) + rule.onNodeWithTag(PHOTO_PROGRESS_TAG).assertIsDisplayed() + rule.onNodeWithTag(PHOTO_PROGRESS_FILL_TAG).assertExists() + } + + @Test + fun overlayShowsEmptyTrackWhilePreparing() { + setOverlay(ChatPhotoPhase.Preparing) + rule.onNodeWithTag(PHOTO_PROGRESS_TAG).assertIsDisplayed() + rule.onNodeWithTag(PHOTO_PROGRESS_FILL_TAG).assertDoesNotExist() + } + + @Test + fun overlayHidesWhenSent() { + setOverlay(ChatPhotoPhase.Sent) + rule.onNodeWithTag(PHOTO_PROGRESS_TAG).assertDoesNotExist() + } + + @Test + fun overlayHidesImmediatelyWhenFailed() { + setOverlay(ChatPhotoPhase.Failed) + rule.onNodeWithTag(PHOTO_PROGRESS_TAG).assertDoesNotExist() + } + + // endregion + + // region bubble + + @Test + fun blurhashOnlyBubbleIsNotClickable() { + var clicks = 0 + rule.setContent { + DesignSystem { + ChatPhotoBubble( + isFromSelf = false, maxWidth = 240.dp, imageWidth = 100, imageHeight = 100, + blurHash = null, blurhashOnly = true, onClick = { clicks++ }, + ) + } + } + rule.onNodeWithTag(PHOTO_BUBBLE_TAG, useUnmergedTree = true).performClick() + assertEquals(0, clicks) + } + + @Test + fun loadedBubbleIsClickable() { + var clicks = 0 + rule.setContent { + DesignSystem { + ChatPhotoBubble( + isFromSelf = false, maxWidth = 240.dp, imageWidth = 100, imageHeight = 100, + blurHash = null, onClick = { clicks++ }, + ) + } + } + rule.onNodeWithTag(PHOTO_BUBBLE_TAG, useUnmergedTree = true).performClick() + // The bubble waits out the double-tap window before reporting a single tap. + rule.mainClock.advanceTimeBy(1_000) + assertEquals(1, clicks) + } + + @Test + fun unavailableBubbleSaysSoAndIsNotClickable() { + var clicks = 0 + rule.setContent { + DesignSystem { + ChatPhotoBubble( + isFromSelf = false, maxWidth = 240.dp, imageWidth = 100, imageHeight = 100, + blurHash = null, unavailable = true, onClick = { clicks++ }, + ) + } + } + rule.onNodeWithText("This photo can’t be displayed").assertIsDisplayed() + rule.onNodeWithTag(PHOTO_BUBBLE_TAG, useUnmergedTree = true).performClick() + assertEquals(0, clicks) + } + + @Test + fun captionIsItsOwnBubble() { + rule.setContent { + DesignSystem { + ChatPhotoBubble( + isFromSelf = true, maxWidth = 240.dp, imageWidth = 100, imageHeight = 100, + blurHash = null, caption = "Lunch", + ) + } + } + rule.onNodeWithTag(PHOTO_CAPTION_TAG).assertIsDisplayed() + } + + // endregion + + // region chips + + private fun setChips(state: ComposerPhotoChipState, onRetry: (String) -> Unit = {}) = rule.setContent { + DesignSystem { + ComposerPhotoChips( + chips = listOf(ComposerPhotoChip("a", null, state)), + onRemove = {}, + onRetry = onRetry, + ) + } + } + + @Test + fun uploadedShowsNothing() { + setChips(ComposerPhotoChipState.Uploaded) + rule.onNodeWithContentDescription("Retry photo upload").assertDoesNotExist() + rule.onNodeWithContentDescription("Photo can't be sent").assertDoesNotExist() + rule.onNodeWithContentDescription("Remove photo").assertExists() + } + + @Test + fun retryableOffersRetry() { + var retried: String? = null + setChips(ComposerPhotoChipState.Failed(retryable = true)) { retried = it } + rule.onNodeWithContentDescription("Retry photo upload").performClick() + assertEquals("a", retried) + rule.onNodeWithContentDescription("Photo can't be sent").assertDoesNotExist() + } + + @Test + fun notRetryableShowsError() { + setChips(ComposerPhotoChipState.Failed(retryable = false)) + rule.onNodeWithContentDescription("Photo can't be sent").assertExists() + rule.onNodeWithContentDescription("Retry photo upload").assertDoesNotExist() + } + + // endregion + + // region viewer + + @Test + fun shareIsDisabledUntilTheFullImageLoads() { + rule.setContent { + DesignSystem { + ChatMediaViewer( + model = null, fullImageLoaded = false, onFullImageLoaded = {}, + onShare = {}, onDismiss = {}, + ) + } + } + rule.onNodeWithTag(VIEWER_SHARE_TAG).assertIsNotEnabled() + rule.onNodeWithTag(VIEWER_CLOSE_TAG).assertIsEnabled() + } + + @Test + fun shareIsEnabledOnceLoaded() { + rule.setContent { + DesignSystem { + ChatMediaViewer( + model = null, fullImageLoaded = true, onFullImageLoaded = {}, + onShare = {}, onDismiss = {}, + ) + } + } + rule.onNodeWithTag(VIEWER_SHARE_TAG).assertIsEnabled() + } + + @Test + fun doubleTapTogglesZoom() { + val zoom = ChatMediaZoomState() + rule.setContent { + DesignSystem { + ChatMediaViewer( + model = null, fullImageLoaded = true, onFullImageLoaded = {}, + onShare = {}, onDismiss = {}, zoomState = zoom, reduceMotion = true, + ) + } + } + assertFalse(zoom.isZoomed) + rule.onNodeWithTag(VIEWER_IMAGE_TAG).performTouchInput { doubleClick(center) } + rule.waitForIdle() + assertTrue(zoom.isZoomed) + assertEquals(2.5f, zoom.scale) + rule.onNodeWithTag(VIEWER_IMAGE_TAG).performTouchInput { doubleClick(center) } + rule.waitForIdle() + assertFalse(zoom.isZoomed) + } + + // endregion +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransitionTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransitionTest.kt new file mode 100644 index 0000000000..4d075d3db2 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransitionTest.kt @@ -0,0 +1,119 @@ +package com.flipcash.shared.chat.ui.media + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ChatPhotoTransitionTest { + private val container = Size(1200f, 2400f) + private val bubble = Rect(600f, 400f, 1100f, 750f) + private val open = Rect(0f, 600f, 1200f, 1800f) + + @Test + fun `progress zero sits on the bubble and one on the anchor`() { + val start = ChatPhotoTransition.frame(bubble, 48f, open, anchorPull = 0f, progress = 0f) + assertEquals(bubble, start.rect) + assertEquals(48f, start.radiusPx) + assertEquals(0f, start.backdrop) + val end = ChatPhotoTransition.frame(bubble, 48f, open, anchorPull = 0f, progress = 1f) + assertEquals(open, end.rect) + assertEquals(0f, end.radiusPx) + assertEquals(1f, end.backdrop) + assertEquals(1f, end.chromeAlpha) + } + + @Test + fun `halfway is the midpoint of both rects and corners`() { + val mid = ChatPhotoTransition.frame(bubble, 48f, open, anchorPull = 0f, progress = 0.5f) + assertEquals(Rect(300f, 500f, 1150f, 1275f), mid.rect) + assertEquals(24f, mid.radiusPx) + assertEquals(0.5f, mid.backdrop) + } + + @Test + fun `a pulled anchor keeps its corners and dims the backdrop`() { + val pulled = ChatPhotoTransition.frame(bubble, 48f, open, anchorPull = 1f, progress = 1f) + assertEquals(48f, pulled.radiusPx) + assertEquals(ChatPhotoTransition.MIN_DRAG_BACKDROP, pulled.backdrop) + assertEquals(0f, pulled.chromeAlpha) + } + + @Test + fun `without a bubble the photo fades and shrinks about its own center`() { + val gone = ChatPhotoTransition.frame(null, 48f, open, anchorPull = 0f, progress = 0f) + assertEquals(0f, gone.contentAlpha) + assertEquals(open.center, gone.rect.center) + assertEquals(open.width * ChatPhotoTransition.DETACHED_SCALE, gone.rect.width) + val shown = ChatPhotoTransition.frame(null, 48f, open, anchorPull = 0f, progress = 1f) + assertEquals(1f, shown.contentAlpha) + assertEquals(open, shown.rect) + } + + @Test + fun `progress outside zero to one is clamped`() { + assertEquals(bubble, ChatPhotoTransition.frame(bubble, 0f, open, 0f, -0.2f).rect) + assertEquals(open, ChatPhotoTransition.frame(bubble, 0f, open, 0f, 1.3f).rect) + } + + @Test + fun `a pull shrinks the photo about its center and follows the finger`() { + val none = ChatPhotoTransition.draggedRect(open, Offset.Zero, 0f) + assertEquals(open, none) + val full = ChatPhotoTransition.draggedRect(open, Offset(100f, 400f), 1f) + assertEquals(open.width * ChatPhotoTransition.MIN_DRAG_SCALE, full.width) + assertEquals(open.center + Offset(100f, 400f), full.center) + } + + @Test + fun `pull progress reaches one at half the container height in any direction`() { + assertEquals(0f, ChatPhotoTransition.dragProgress(Offset.Zero, 2400f)) + assertEquals(0.5f, ChatPhotoTransition.dragProgress(Offset(0f, 600f), 2400f)) + assertEquals(1f, ChatPhotoTransition.dragProgress(Offset(-1200f, 0f), 2400f)) + assertEquals(1f, ChatPhotoTransition.dragProgress(Offset(0f, 5000f), 2400f)) + assertEquals(0f, ChatPhotoTransition.dragProgress(Offset(0f, 5000f), 0f)) + } + + @Test + fun `a short slow release springs back`() { + assertFalse(ChatPhotoTransition.shouldDismiss(Offset(0f, 200f), Offset(0f, 300f), 2400f, 3f)) + } + + @Test + fun `a long release dismisses`() { + assertTrue(ChatPhotoTransition.shouldDismiss(Offset(0f, 400f), Offset.Zero, 2400f, 3f)) + assertTrue(ChatPhotoTransition.shouldDismiss(Offset(-300f, -300f), Offset.Zero, 2400f, 3f)) + } + + @Test + fun `a downward fling dismisses even from a short pull`() { + assertTrue(ChatPhotoTransition.shouldDismiss(Offset(0f, 80f), Offset(0f, 3000f), 2400f, 3f)) + } + + @Test + fun `a fling that is too slow or upward does not`() { + assertFalse(ChatPhotoTransition.shouldDismiss(Offset(0f, 80f), Offset(0f, 2000f), 2400f, 3f)) + assertFalse(ChatPhotoTransition.shouldDismiss(Offset(0f, -80f), Offset(0f, 3000f), 2400f, 3f)) + } + + @Test + fun `a bubble that is off screen cannot be flown to`() { + assertTrue(ChatPhotoTransition.sourceUsable(bubble, container)) + assertTrue(ChatPhotoTransition.sourceUsable(Rect(100f, -100f, 600f, 100f), container)) + assertFalse(ChatPhotoTransition.sourceUsable(Rect(100f, -500f, 600f, -10f), container)) + assertFalse(ChatPhotoTransition.sourceUsable(Rect(100f, 2500f, 600f, 2900f), container)) + assertFalse(ChatPhotoTransition.sourceUsable(Rect.Zero, container)) + } + + @Test + fun `the open rect fits the photo inside the container`() { + val wide = ChatPhotoTransition.fittedRect(container, Size(4000f, 2000f)) + assertEquals(Rect(0f, 900f, 1200f, 1500f), wide) + val tall = ChatPhotoTransition.fittedRect(container, Size(1000f, 4000f)) + assertEquals(Rect(300f, 0f, 900f, 2400f), tall) + assertEquals(Rect(Offset.Zero, container), ChatPhotoTransition.fittedRect(container, Size.Zero)) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ComposerChipEdgeFadeTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ComposerChipEdgeFadeTest.kt new file mode 100644 index 0000000000..8893ecc697 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ComposerChipEdgeFadeTest.kt @@ -0,0 +1,22 @@ +package com.flipcash.shared.chat.ui.media + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ComposerChipEdgeFadeTest { + @Test + fun atRestWithRoomToTheEndOnlyTheEndFades() = + assertEquals(ChipEdgeFades(start = false, end = true), chipEdgeFades(false, true)) + + @Test + fun scrolledToTheEndOnlyTheStartFades() = + assertEquals(ChipEdgeFades(start = true, end = false), chipEdgeFades(true, false)) + + @Test + fun fitsWithoutScrollingNothingFades() = + assertEquals(ChipEdgeFades(start = false, end = false), chipEdgeFades(false, false)) + + @Test + fun midScrollBothFade() = + assertEquals(ChipEdgeFades(start = true, end = true), chipEdgeFades(true, true)) +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubbleTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubbleTest.kt new file mode 100644 index 0000000000..ec5056c396 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubbleTest.kt @@ -0,0 +1,130 @@ +package com.flipcash.shared.chat.ui.media + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import com.flipcash.services.chat.BlobOpenFailure +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.BlobMetadata +import com.flipcash.services.chat.MessageEncryption +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ImageMetadata +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.chat.MediaItemRendition +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.media.ChatPhotoUnavailable +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.PhotoMessageContext +import com.flipcash.shared.chat.ui.BubblePosition +import com.flipcash.shared.chat.ui.ContentBubble +import com.getcode.theme.DesignSystem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +@RunWith(RobolectricTestRunner::class) +class MediaMessageBubbleTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val chatId = ChatId(ByteArray(16) { it.toByte() }) + + private val photo = MessageContent.Media( + items = listOf( + MediaItem( + listOf( + MediaItemRendition( + role = MediaItemRendition.Role.ORIGINAL, + blobId = BlobId(ByteArray(32) { 1 }), + blob = BlobMetadata( + mimeType = "image/jpeg", + sizeBytes = 10, + downloadUrl = "https://cdn/blob", + image = ImageMetadata(width = 800, height = 600, blurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj"), + ), + ), + ), + ), + ), + caption = MessageContent.Text("sunset"), + ) + + private fun item(previewing: Boolean) = ChatListItem.ContentBubble( + messageId = 7, + contentIndex = 0, + content = photo, + isFromSelf = false, + timestamp = Instant.fromEpochSeconds(1_000), + photo = PhotoMessageContext(chatId, sealed = false, redacted = false, previewing = previewing), + ) + + @Test + fun `a media message renders a photo bubble with its caption`() { + composeTestRule.setContent { + DesignSystem { ContentBubble(item = item(previewing = true), position = BubblePosition.Solo) } + } + + composeTestRule.onNodeWithTag(PHOTO_BUBBLE_TAG).assertIsDisplayed() + composeTestRule.onNodeWithText("sunset").assertIsDisplayed() + } + + @Test + fun `a photo only opens when it is showing, delivered and loaded`() { + assertTrue(canOpenPhoto(blurhashOnly = false, failed = false, unavailable = false)) + assertFalse(canOpenPhoto(blurhashOnly = true, failed = false, unavailable = false)) + assertFalse(canOpenPhoto(blurhashOnly = false, failed = true, unavailable = false)) + assertFalse(canOpenPhoto(blurhashOnly = false, failed = false, unavailable = true)) + } + + @Test + fun `a key that has not arrived leaves the blurhash up instead of reporting unavailable`() { + assertFalse(isPhotoUnavailable(ChatPhotoUnavailable.Open(BlobOpenFailure.KeyPending))) + assertTrue(isPhotoUnavailable(ChatPhotoUnavailable.Redacted())) + assertTrue(isPhotoUnavailable(IllegalStateException("decode"))) + assertEquals(true, isPhotoUnavailable(RuntimeException(ChatPhotoUnavailable.NotFound()))) + } + + @Test + fun ownSealedPhotoKeepsItsSender() { + // A sealed photo picks its key roles from the sender. Dropping the own id left the photo + // with no sender, so it never opened and drew as "can't be displayed". + val self = List(32) { 7.toByte() } + val message = ChatMessage( + messageId = 1, + senderId = self, + content = listOf(photo), + timestamp = Instant.fromEpochMilliseconds(0), + unreadSeq = 0, + isFromSelf = true, + encryption = MessageEncryption.Decrypted( + MessageContent.Encrypted(scheme = 1, nonce = ByteArray(24), ciphertext = ByteArray(8)), + ), + ) + + val context = message.photoContext(chatId, previewing = false)!! + + assertEquals(self, context.senderId) + assertTrue(context.sealed) + } + + @Test + fun messageWithoutPhotoHasNoPhotoContext() { + val message = ChatMessage( + messageId = 1, + senderId = null, + content = listOf(MessageContent.Text("hi")), + timestamp = Instant.fromEpochMilliseconds(0), + unreadSeq = 0, + ) + + assertEquals(null, message.photoContext(chatId, previewing = false)) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/resources/chat_media.json b/apps/flipcash/shared/chat-ui/src/test/resources/chat_media.json new file mode 100644 index 0000000000..54f976f273 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/resources/chat_media.json @@ -0,0 +1,639 @@ +{ + "algorithm": "chat-media-photos", + "note": "Behavior fixture for docs/superpowers/specs/2026-09-28-chat-media-photos-design.md. Heights are points or dp as floats; compare with a 0.001 tolerance.", + "maxAttachments": 10, + "minAspect": 0.5, + "maxAspect": 2.0, + "jpegQualityLadder": [ + 0.9, + 0.8, + 0.7, + 0.6 + ], + "uploadMimeType": "image/jpeg", + "fanOut": [ + { + "name": "text-only", + "chips": [], + "text": "hello", + "replyTo": null, + "messages": [ + { + "kind": "text", + "chip": null, + "caption": null, + "text": "hello", + "replyTo": null + } + ], + "note": "No chips: a text message, exactly as today." + }, + { + "name": "text-only-reply", + "chips": [], + "text": "hello", + "replyTo": "m1", + "messages": [ + { + "kind": "text", + "chip": null, + "caption": null, + "text": "hello", + "replyTo": "m1" + } + ], + "note": "No chips, with a reply: a text reply, exactly as today." + }, + { + "name": "empty", + "chips": [], + "text": "", + "replyTo": null, + "messages": [], + "note": "Nothing to send. Send is disabled, so this pins that the builder agrees." + }, + { + "name": "one-photo", + "chips": [ + "a" + ], + "text": "", + "replyTo": null, + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": null + } + ], + "note": "One chip, no text: one media message with no caption." + }, + { + "name": "one-photo-caption", + "chips": [ + "a" + ], + "text": "look", + "replyTo": null, + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": "look", + "text": null, + "replyTo": null + } + ], + "note": "One chip with text: the text is the caption." + }, + { + "name": "three-photos-caption", + "chips": [ + "a", + "b", + "c" + ], + "text": "from today", + "replyTo": null, + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "b", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "c", + "caption": "from today", + "text": null, + "replyTo": null + } + ], + "note": "The caption rides on the last message, so it lands under the last image." + }, + { + "name": "three-photos-reply", + "chips": [ + "a", + "b", + "c" + ], + "text": "", + "replyTo": "m1", + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": "m1" + }, + { + "kind": "media", + "chip": "b", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "c", + "caption": null, + "text": null, + "replyTo": null + } + ], + "note": "The reply attaches to the first message only." + }, + { + "name": "three-photos-caption-reply", + "chips": [ + "a", + "b", + "c" + ], + "text": "these?", + "replyTo": "m1", + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": "m1" + }, + { + "kind": "media", + "chip": "b", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "c", + "caption": "these?", + "text": null, + "replyTo": null + } + ], + "note": "Reply on the first, caption on the last." + }, + { + "name": "one-photo-caption-reply", + "chips": [ + "a" + ], + "text": "this one", + "replyTo": "m1", + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": "this one", + "text": null, + "replyTo": "m1" + } + ], + "note": "With one chip, the same message carries both reply and caption." + }, + { + "name": "ten-photos", + "chips": [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j" + ], + "text": "", + "replyTo": null, + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "b", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "c", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "d", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "e", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "f", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "g", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "h", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "i", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "j", + "caption": null, + "text": null, + "replyTo": null + } + ], + "note": "The composer maximum. Order is chip order." + } + ], + "bubble": [ + { + "name": "square", + "imageWidth": 1000, + "imageHeight": 1000, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 240.0, + "cropped": false + }, + "note": "1:1 fills the width, same height." + }, + { + "name": "landscape-4-3", + "imageWidth": 4032, + "imageHeight": 3024, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 180.0, + "cropped": false + }, + "note": "Landscape phone photo." + }, + { + "name": "portrait-3-4", + "imageWidth": 3024, + "imageHeight": 4032, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 320.0, + "cropped": false + }, + "note": "Portrait phone photo." + }, + { + "name": "wide-at-limit", + "imageWidth": 2000, + "imageHeight": 1000, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 120.0, + "cropped": false + }, + "note": "Exactly 1:2 is not cropped." + }, + { + "name": "panorama", + "imageWidth": 8000, + "imageHeight": 1000, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 120.0, + "cropped": true + }, + "note": "Wider than 1:2 clamps to 1:2 and crops." + }, + { + "name": "tall-at-limit", + "imageWidth": 1000, + "imageHeight": 2000, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 480.0, + "cropped": false + }, + "note": "Exactly 2:1 is not cropped." + }, + { + "name": "screenshot", + "imageWidth": 1179, + "imageHeight": 2556, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 480.0, + "cropped": true + }, + "note": "An iPhone screenshot is taller than 2:1 and crops." + }, + { + "name": "tiny-upscaled", + "imageWidth": 40, + "imageHeight": 30, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 180.0, + "cropped": false + }, + "note": "Small images still take the full width. The bubble never shrinks to the image." + }, + { + "name": "missing-metadata", + "imageWidth": 0, + "imageHeight": 0, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 240.0, + "cropped": false + }, + "note": "No dimensions (should not happen after READY): a square placeholder." + }, + { + "name": "android-dp", + "imageWidth": 4032, + "imageHeight": 3024, + "maxWidth": 264.5, + "expected": { + "width": 264.5, + "height": 198.375, + "cropped": false + }, + "note": "Fractional widths are fine; compare with 0.001 tolerance." + } + ], + "downscale": [ + { + "name": "within-bounds", + "sourceWidth": 1200, + "sourceHeight": 900, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 0, + "expected": { + "width": 1200, + "height": 900 + }, + "note": "Already fits: unchanged." + }, + { + "name": "edge-bound-landscape", + "sourceWidth": 4032, + "sourceHeight": 3024, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 0, + "expected": { + "width": 2048, + "height": 1536 + }, + "note": "Long edge binds." + }, + { + "name": "edge-bound-portrait", + "sourceWidth": 3024, + "sourceHeight": 4032, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 0, + "expected": { + "width": 1536, + "height": 2048 + }, + "note": "Long edge binds, portrait." + }, + { + "name": "asymmetric-bounds", + "sourceWidth": 4000, + "sourceHeight": 4000, + "maxWidth": 3000, + "maxHeight": 1000, + "maxPixels": 0, + "expected": { + "width": 1000, + "height": 1000 + }, + "note": "The tighter of width and height binds." + }, + { + "name": "pixels-bind-first", + "sourceWidth": 4032, + "sourceHeight": 3024, + "maxWidth": 4096, + "maxHeight": 4096, + "maxPixels": 4000000, + "expected": { + "width": 2309, + "height": 1732 + }, + "note": "Edges would allow the original; max_pixels binds instead." + }, + { + "name": "pixels-and-edges", + "sourceWidth": 8000, + "sourceHeight": 6000, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 2000000, + "expected": { + "width": 1632, + "height": 1224 + }, + "note": "max_pixels is tighter than the edge limit here." + }, + { + "name": "unbounded", + "sourceWidth": 5000, + "sourceHeight": 3000, + "maxWidth": 0, + "maxHeight": 0, + "maxPixels": 0, + "expected": { + "width": 5000, + "height": 3000 + }, + "note": "No constraints: unchanged." + }, + { + "name": "never-upscale", + "sourceWidth": 300, + "sourceHeight": 200, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 16000000, + "expected": { + "width": 300, + "height": 200 + }, + "note": "Small source: unchanged." + }, + { + "name": "panorama-min-edge", + "sourceWidth": 20000, + "sourceHeight": 10, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 0, + "expected": { + "width": 2048, + "height": 1 + }, + "note": "An edge never goes below 1." + }, + { + "name": "odd-pixels", + "sourceWidth": 3001, + "sourceHeight": 2999, + "maxWidth": 0, + "maxHeight": 0, + "maxPixels": 1000000, + "expected": { + "width": 1000, + "height": 999 + }, + "note": "Floor rounding still fits max_pixels." + } + ], + "constraintSelection": [ + { + "name": "exact-first", + "patterns": [ + "image/jpeg", + "image/*", + "*/*" + ], + "mimeType": "image/jpeg", + "expectedIndex": 0, + "note": "Exact match wins when listed first." + }, + { + "name": "wildcard-image", + "patterns": [ + "image/png", + "image/*", + "*/*" + ], + "mimeType": "image/jpeg", + "expectedIndex": 1, + "note": "image/png does not match; image/* does." + }, + { + "name": "catch-all", + "patterns": [ + "video/*", + "*/*" + ], + "mimeType": "image/jpeg", + "expectedIndex": 1, + "note": "Only */* matches." + }, + { + "name": "none", + "patterns": [ + "video/*", + "application/pdf" + ], + "mimeType": "image/jpeg", + "expectedIndex": null, + "note": "No entry: do not upload." + }, + { + "name": "policy-order-wins", + "patterns": [ + "*/*", + "image/jpeg" + ], + "mimeType": "image/jpeg", + "expectedIndex": 0, + "note": "Policy order is authoritative, even if a later entry is more specific." + } + ], + "strings": [ + { + "name": "no-caption", + "caption": null, + "expected": { + "snippet": "Photo", + "preview": "πŸ“· Photo" + }, + "note": "No caption: the word Photo." + }, + { + "name": "caption", + "caption": "from today", + "expected": { + "snippet": "from today", + "preview": "πŸ“· from today" + }, + "note": "The caption replaces the word Photo." + }, + { + "name": "emoji-caption", + "caption": "πŸŽ‰ done", + "expected": { + "snippet": "πŸŽ‰ done", + "preview": "πŸ“· πŸŽ‰ done" + }, + "note": "Captions are used as written." + } + ] +} From 40782b932554f0368ae55d632b9bf8da1ca961be Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 20:39:43 -0400 Subject: [PATCH 2/7] fix(chat-ui): blue upload fill, and aim the first shot at its chip The upload fill used CodeTheme.colors.brand, which Flipcash sets to 0xFF19191A, so it barely showed on the dark track. It now uses ChatMediaBlue (0xFF0A84FF, UIKit's systemBlue in dark mode), as iOS does. The camera's more dot shares the same constant. The first camera shot flew toward the top of the screen. Its chip's header row eases in from behind the field's clip, and boundsInRoot() reports a fully clipped node as Rect.Zero, the window's top-left. The landing target now comes from the chip's unclipped position and size. Later shots were fine because the header row was already visible. --- .../com/flipcash/shared/chat/ui/media/AttachSurface.kt | 8 +++++++- .../com/flipcash/shared/chat/ui/media/ChatCameraPanel.kt | 5 +---- .../shared/chat/ui/media/ChatPhotoProgressOverlay.kt | 5 ++++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/AttachSurface.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/AttachSurface.kt index 1d24e55664..2975ffb922 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/AttachSurface.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/AttachSurface.kt @@ -42,6 +42,8 @@ import androidx.compose.animation.core.Animatable import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.unit.toSize import androidx.compose.animation.core.spring import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.CompositionContext @@ -601,7 +603,11 @@ private fun AttachSurfaceWindow( val now = currentPhase val plusRadius = plusRect.height / 2f - val chip = currentLanding()?.takeIf { it.isAttached }?.boundsInRoot()?.translate(shift) + // Unclipped bounds: the first chip's header row eases in from behind the field's clip, and + // boundsInRoot() reports a fully clipped chip as Rect.Zero, the window's top-left corner. + val chip = currentLanding()?.takeIf { it.isAttached } + ?.let { Rect(it.positionInRoot(), it.size.toSize()) } + ?.translate(shift) // From the shutter on, the card shrinks the taken frame toward the chip. The photo is // still being saved, so until its chip lays out it heads for where the chip will be, // beside "+" and above it, and retargets onto the chip once there is one. diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatCameraPanel.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatCameraPanel.kt index 080dd907b4..d376296c16 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatCameraPanel.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatCameraPanel.kt @@ -388,7 +388,7 @@ private fun CameraPreview( .offset(x = -CodeTheme.dimens.staticGrid.x1 / 2, y = CodeTheme.dimens.staticGrid.x1 / 2) .size(CodeTheme.dimens.staticGrid.x2) .graphicsLayer { alpha = dot.coerceIn(0f, 1f) } - .background(MoreDotColor, CircleShape), + .background(ChatMediaBlue, CircleShape), ) } } @@ -434,9 +434,6 @@ private val StackExit = scaleOut( transformOrigin = StackOrigin, ) + fadeOut(ChatAnimations.swap) -// SwiftUI's Color.blue in dark mode. -private val MoreDotColor = Color(0xFF0A84FF) - private fun ChatCameraLens.selector(): CameraSelector = when (this) { ChatCameraLens.Back -> CameraSelector.DEFAULT_BACK_CAMERA ChatCameraLens.Front -> CameraSelector.DEFAULT_FRONT_CAMERA diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt index dd6e05b26c..13a265864b 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt @@ -166,10 +166,13 @@ private fun Fill(widthFraction: () -> Float, offsetFraction: () -> Float) { } } .fillMaxSize() - .background(CodeTheme.colors.brand), + .background(ChatMediaBlue), ) } +// UIKit's systemBlue in dark mode, which iOS uses for the upload fill and the camera's more dot. +internal val ChatMediaBlue = Color(0xFF0A84FF) + @Preview(widthDp = 200, heightDp = 100) @PreviewWrapper(FlipcashThemeWrapper::class) @Composable From 3b98a07a2d97004c044d7ad247a8630b90f2cfce Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 21:35:48 -0400 Subject: [PATCH 3/7] fix(chat-ui): keep a sent photo's progress bar up once its bubble is drawn A photo that finished uploading in the composer reaches Sent soon after the tap, but the list scrolls the new bubble in about 450 ms later. On the Seeker the bar showed for 2 frames of a 20 fps recording before it faded, so it read as missing. Once the overlay is drawn it now stays up for at least 600 ms before honouring Sent. A bubble first drawn already sent still never shows it. --- .../chat/ui/media/ChatPhotoProgressOverlay.kt | 17 +++++++++- .../chat/ui/media/ChatPhotoMediaUiTest.kt | 31 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt index 13a265864b..0225ed27a6 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt @@ -15,7 +15,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -34,6 +38,7 @@ import androidx.compose.ui.tooling.preview.PreviewWrapper import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.core.R import com.getcode.theme.CodeTheme +import kotlinx.coroutines.delay /** Where an outgoing photo is in its trip, as the progress capsule on the bubble shows it. */ sealed interface ChatPhotoPhase { @@ -63,6 +68,7 @@ private const val SEGMENT_FRACTION = 0.35f private const val FRACTION_MILLIS = 200 private const val SENT_FADE_MILLIS = 250 private const val SLIDE_MILLIS = 1100 +internal const val PHOTO_PROGRESS_MIN_VISIBLE_MILLIS = 600L /** * The capsule at the bottom-end of a photo bubble that shows an upload's progress. Fill the @@ -80,7 +86,16 @@ fun ChatPhotoProgressOverlay( modifier: Modifier = Modifier, reduceMotion: Boolean = rememberReducedMotion(), ) { - val sent = phase == ChatPhotoPhase.Sent + // The row can be drawn well after the send began (the list scrolls it in), so a quick send + // would fade the bar out before anyone sees it. Once drawn, it stays up for a minimum. A row + // first drawn already sent never shows it. + val drawnSent = remember { phase == ChatPhotoPhase.Sent } + var shownLongEnough by remember { mutableStateOf(drawnSent) } + LaunchedEffect(Unit) { + delay(PHOTO_PROGRESS_MIN_VISIBLE_MILLIS) + shownLongEnough = true + } + val sent = phase == ChatPhotoPhase.Sent && shownLongEnough val alpha by animateFloatAsState( targetValue = if (sent) 0f else 1f, animationSpec = if (reduceMotion) snap() else tween(SENT_FADE_MILLIS), diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt index 9df5298877..810d360bf0 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt @@ -2,6 +2,9 @@ package com.flipcash.shared.chat.ui.media import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled @@ -57,6 +60,34 @@ class ChatPhotoMediaUiTest { rule.onNodeWithTag(PHOTO_PROGRESS_TAG).assertDoesNotExist() } + @Test + fun overlayStaysUpForAMinimumOnceDrawnEvenIfSentArrivesSoonAfter() { + var phase by mutableStateOf(ChatPhotoPhase.Sending) + rule.mainClock.autoAdvance = false + rule.setContent { + DesignSystem { + Box(Modifier.size(200.dp)) { ChatPhotoProgressOverlay(phase, reduceMotion = true) } + } + } + rule.mainClock.advanceTimeByFrame() + phase = ChatPhotoPhase.Sent + advanceFramesFor(PHOTO_PROGRESS_MIN_VISIBLE_MILLIS / 2) + rule.onNodeWithTag(PHOTO_PROGRESS_TAG).assertIsDisplayed() + + advanceFramesFor(PHOTO_PROGRESS_MIN_VISIBLE_MILLIS) + rule.onNodeWithTag(PHOTO_PROGRESS_TAG).assertDoesNotExist() + } + + // One frame at a time, idling between them so each state change recomposes before the clock + // moves on. + private fun advanceFramesFor(millis: Long) { + val end = rule.mainClock.currentTime + millis + while (rule.mainClock.currentTime < end) { + rule.waitForIdle() + rule.mainClock.advanceTimeByFrame() + } + } + @Test fun overlayHidesImmediatelyWhenFailed() { setOverlay(ChatPhotoPhase.Failed) From f83d57f45b9b4734a2f60a0e67499ff923647200 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 10:50:37 -0400 Subject: [PATCH 4/7] fix(messenger): send photos whose upload failed offline Only a failure a retry can't fix blocks the send now. A photo that failed offline goes into the transcript as Sending and retries from there, instead of holding the message in the composer. --- .../flipcash/app/messenger/internal/ChatViewModel.kt | 8 ++++++-- .../app/messenger/internal/ChatComposerPhotosTest.kt | 12 +++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index d03d7c0785..43159e4093 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -532,9 +532,13 @@ internal class ChatViewModel @Inject constructor( val acceptsMedia: Boolean get() = chatId != null && editing == null - /** A photo that failed to upload blocks sending until it is retried or removed. */ + /** + * A photo whose upload failed for good (one a retry can't fix) blocks sending until it is + * removed. A retryable failure, such as being offline, is sent anyway: the send uploads it + * again and the bubble shows the result. + */ val hasFailedPhoto: Boolean - get() = composerPhotos.any { uploadStates[it.id] is ChatMediaUploadState.Failed } + get() = composerPhotos.any { (uploadStates[it.id] as? ChatMediaUploadState.Failed)?.retryable == false } /** Whether the send control does anything: text or a photo to send, and no failed photo. */ fun canSendComposer(hasText: Boolean): Boolean = diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatComposerPhotosTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatComposerPhotosTest.kt index 670e5a3e2d..6dda753694 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatComposerPhotosTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatComposerPhotosTest.kt @@ -66,11 +66,21 @@ class ChatComposerPhotosTest { } @Test - fun failedChipDisablesSend() { + fun retryableFailedChipStillSends() { + // An upload that failed offline is sent anyway; the bubble retries it from the transcript. val state = reduce( stage(base, "a", "b"), ChatViewModel.Event.UploadStatesChanged(mapOf("a" to failed)), ) + assertTrue(state.canSendComposer(hasText = true)) + } + + @Test + fun unretryableFailedChipDisablesSend() { + val state = reduce( + stage(base, "a", "b"), + ChatViewModel.Event.UploadStatesChanged(mapOf("a" to failed.copy(retryable = false))), + ) assertFalse(state.canSendComposer(hasText = true)) // Removing the failed chip frees the send. assertTrue(reduce(state, ChatViewModel.Event.RemovePhoto("a")).canSendComposer(hasText = true)) From 137124a2c2983794e5d93bce81cea4fbda506b03 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 11:08:36 -0400 Subject: [PATCH 5/7] fix(messenger): refresh reactions off the event bus Offline, the reaction refresh's RPC doesn't return. Its handler waited on it inline, and the event bus has no buffer, so every event after it queued until the network came back: a picked photo never reached the composer. The refresh is launched now, as the read pointer's already is. --- .../app/messenger/internal/ChatViewModel.kt | 4 +- .../messenger/internal/ChatEventStallTest.kt | 174 ++++++++++++++++++ 2 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 43159e4093..7ec63a318e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -2149,7 +2149,9 @@ internal class ChatViewModel @Inject constructor( .onEach { event -> val chatId = stateFlow.value.chatId ?: return@onEach if (event.messageIds.isEmpty()) return@onEach - chatCoordinator.refreshReactions(chatId, event.messageIds) + // Launched: offline the RPC doesn't return, and waiting on it here holds up every + // event after it on the shared bus. + viewModelScope.launch { chatCoordinator.refreshReactions(chatId, event.messageIds) } } .launchIn(viewModelScope) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt new file mode 100644 index 0000000000..ad857a7b7b --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt @@ -0,0 +1,174 @@ +package com.flipcash.app.messenger.internal + +import android.content.ClipboardManager +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.flipcash.analytics.PropertyValue +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.contacts.ContactCoordinator +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.app.funding.PurchaseMethodController +import com.flipcash.app.messenger.internal.link.LinkCardClassifier +import com.flipcash.app.messenger.internal.link.LinkCardResolver +import com.flipcash.app.session.CashLinkClaims +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.userflags.UserFlagsCoordinator +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.models.UserProfile +import com.flipcash.app.persistence.sources.UserProfileDataSource +import com.flipcash.services.chat.E2eePolicy +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatDraftStore +import com.flipcash.shared.payments.ContactPaymentDelegate +import com.flipcash.shared.payments.TipPaymentDelegate +import com.getcode.libs.emojis.reactions.EmojiCatalogLoader +import com.getcode.libs.emojis.reactions.RecentReactionsStore +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.exchange.VerifiedFiatCalculator +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.core.bytes +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.PublicKey +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Event handlers share one unbuffered bus, so a handler that suspends inline on the network + * blocks every event dispatched after it until that call returns. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChatEventStallTest { + + @get:Rule + var instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val chatCoordinator = mockk(relaxed = true) { + every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() + } + private val contactCoordinator = mockk(relaxed = true) + private val contactPaymentDelegate = mockk(relaxed = true) + private val tipPaymentDelegate = mockk(relaxed = true) + private val transactionController = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + private val exchange = mockk(relaxed = true) + private val verifiedFiatCalculator = mockk(relaxed = true) + private val purchaseMethodController = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val analytics = RecordingAnalytics() + private val clipboardManager = mockk(relaxed = true) + private val userFlags = mockk(relaxed = true) + private val linkCardClassifier = mockk(relaxed = true) + private val linkCardResolver = mockk(relaxed = true) + private val cashLinkClaims = mockk(relaxed = true) { + every { claimInFlight } returns MutableStateFlow(null) + } + private val chatDraftStore = mockk(relaxed = true) + private val recentReactionsStore = mockk(relaxed = true) + private val emojiCatalogLoader = mockk(relaxed = true) + private val userProfileDataSource = mockk(relaxed = true) + + private val token = mockk(relaxed = true) + private val amount = Fiat(5.0) + private val verifiedFiat = VerifiedFiat(LocalFiat(Fiat(5.0), Fiat(5.0))) + + @Before + fun setUp() { + BottomBarManager.clear() + + every { userManager.accountCluster } returns mockk(relaxed = true) + every { exchange.preferredRate } returns Rate.oneToOne + every { transactionController.limits } returns MutableStateFlow(null) + every { tokenCoordinator.balanceForToken(any()) } returns Fiat(999.0) + every { tipPaymentDelegate.minimumToOpenDmWith(any()) } returns flowOf(null) + coEvery { + verifiedFiatCalculator.compute(any(), any(), any(), any(), any()) + } returns Result.success(verifiedFiat) + coEvery { + contactCoordinator.resolve(any()) + } returns Result.success(mockk(relaxed = true)) + } + + @After + fun tearDown() { + BottomBarManager.clear() + } + + private fun createViewModel(): ChatViewModel = ChatViewModel( + chatCoordinator = chatCoordinator, + mediaUploads = noMediaUploads(), + e2eePolicy = E2eePolicy(), + contactCoordinator = contactCoordinator, + contactPaymentDelegate = contactPaymentDelegate, + tipPaymentDelegate = tipPaymentDelegate, + transactionController = transactionController, + tokenCoordinator = tokenCoordinator, + exchange = exchange, + verifiedFiatCalculator = verifiedFiatCalculator, + purchaseMethodController = purchaseMethodController, + userManager = userManager, + resources = resources, + analytics = analytics, + clipboardManager = clipboardManager, + userFlags = userFlags, + linkCardClassifier = linkCardClassifier, + linkCardResolver = linkCardResolver, + cashLinkClaims = cashLinkClaims, + chatCashLinks = mockk(relaxed = true), + chatDraftStore = chatDraftStore, + recentReactionsStore = recentReactionsStore, + toastController = mockk(relaxed = true), + emojiCatalogLoader = emojiCatalogLoader, + userProfileDataSource = userProfileDataSource, + rosterSearch = mockk(relaxed = true), + dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), + ) + + @Test + fun `a reaction refresh that never returns does not hold up later events`() = runTest(mainCoroutineRule.dispatcher) { + // Offline, the refresh's RPC never returns. The event bus has no buffer, so a handler + // waiting on it inline stalls every event after it: picked photos never reached the composer. + coEvery { chatCoordinator.refreshReactions(any(), any()) } coAnswers { awaitCancellation() } + val chatId = ChatId(UUID.randomUUID().bytes) + + val vm = createViewModel() + vm.dispatchEvent(ChatViewModel.Event.ChatFound(chatId)) + vm.dispatchEvent(ChatViewModel.Event.RefreshReactionIds(listOf(1L))) + vm.dispatchEvent(ChatViewModel.Event.RefreshReactionIds(listOf(2L))) + vm.dispatchEvent(ChatViewModel.Event.AdvanceReadPointer(3L)) + advanceTimeBy(1_000) + + coVerify { chatCoordinator.advanceReadPointer(chatId, 3L) } + } +} From cad627d3bf0c4fa7a88118f89bb5512a12865001 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 11:14:43 -0400 Subject: [PATCH 6/7] fix(chat-ui): show a moving bar until a photo's bytes start uploading The capsule drew an empty track while the photo was encoding and before the first bytes went up, so a slow start looked like no indicator at all. Any phase without a byte count now slides the blue segment; the fill follows the bytes once they report. --- .../chat/ui/media/ChatPhotoProgressOverlay.kt | 28 +++++++++---------- .../chat/ui/media/ChatPhotoMediaUiTest.kt | 24 +++++++++++++--- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt index 0225ed27a6..96fe97b474 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt @@ -74,9 +74,10 @@ internal const val PHOTO_PROGRESS_MIN_VISIBLE_MILLIS = 600L * The capsule at the bottom-end of a photo bubble that shows an upload's progress. Fill the * bubble's box with it; it positions itself. * - * [ChatPhotoPhase.Preparing] is an empty track, [ChatPhotoPhase.Uploading] a fill that follows the - * byte fraction, [ChatPhotoPhase.Processing] and [ChatPhotoPhase.Sending] a segment 35% of the track - * sliding across it. [ChatPhotoPhase.Sent] fades the capsule out; [ChatPhotoPhase.Failed] removes it + * [ChatPhotoPhase.Uploading] with bytes sent is a fill that follows the byte fraction. Every phase + * without a byte count yet ([ChatPhotoPhase.Preparing], an upload before its first bytes, + * [ChatPhotoPhase.Processing], [ChatPhotoPhase.Sending]) is a segment 35% of the track sliding + * across it. [ChatPhotoPhase.Sent] fades the capsule out; [ChatPhotoPhase.Failed] removes it * immediately. With [reduceMotion] nothing animates: the fill jumps, the indeterminate segment sits * still and the fade is a cut. */ @@ -103,6 +104,7 @@ fun ChatPhotoProgressOverlay( ) if (phase == ChatPhotoPhase.Failed || (sent && alpha == 0f)) return + val bytes = (phase as? ChatPhotoPhase.Uploading)?.fraction?.coerceIn(0f, 1f)?.takeIf { it > 0f } val description = stringResource(R.string.description_chatPhotoUploadProgress) val grid = CodeTheme.dimens.staticGrid Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.BottomEnd) { @@ -117,29 +119,27 @@ fun ChatPhotoProgressOverlay( .testTag(PHOTO_PROGRESS_TAG) .semantics { contentDescription = description - if (phase is ChatPhotoPhase.Uploading) { - progressBarRangeInfo = - ProgressBarRangeInfo(phase.fraction.coerceIn(0f, 1f), 0f..1f) + progressBarRangeInfo = when { + bytes != null -> ProgressBarRangeInfo(bytes, 0f..1f) + phase == ChatPhotoPhase.Sent -> ProgressBarRangeInfo(1f, 0f..1f) + else -> ProgressBarRangeInfo.Indeterminate } }, ) { - when (phase) { - is ChatPhotoPhase.Uploading -> { + when { + bytes != null -> { val fraction by animateFloatAsState( - targetValue = phase.fraction.coerceIn(0f, 1f), + targetValue = bytes, animationSpec = if (reduceMotion) snap() else tween(FRACTION_MILLIS), label = "photoProgressFraction", ) Fill(widthFraction = { fraction }, offsetFraction = { 0f }) } - ChatPhotoPhase.Processing, ChatPhotoPhase.Sending -> - IndeterminateSegment(reduceMotion) - // Hold a full bar while the capsule fades. - ChatPhotoPhase.Sent -> Fill(widthFraction = { 1f }, offsetFraction = { 0f }) + phase == ChatPhotoPhase.Sent -> Fill(widthFraction = { 1f }, offsetFraction = { 0f }) - ChatPhotoPhase.Preparing, ChatPhotoPhase.Failed -> Unit + else -> IndeterminateSegment(reduceMotion) } } } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt index 810d360bf0..bfd1f8ae0e 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt @@ -6,10 +6,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.doubleClick +import androidx.compose.ui.test.hasProgressBarRangeInfo import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag @@ -41,17 +44,30 @@ class ChatPhotoMediaUiTest { } @Test - fun overlayShowsWhileUploading() { + fun overlayShowsByteProgressWhileUploading() { setOverlay(ChatPhotoPhase.Uploading(0.4f)) rule.onNodeWithTag(PHOTO_PROGRESS_TAG).assertIsDisplayed() + .assert(hasProgressBarRangeInfo(ProgressBarRangeInfo(0.4f, 0f..1f))) rule.onNodeWithTag(PHOTO_PROGRESS_FILL_TAG).assertExists() } @Test - fun overlayShowsEmptyTrackWhilePreparing() { - setOverlay(ChatPhotoPhase.Preparing) + fun overlayIsIndeterminateWhilePreparing() = assertIndeterminate(ChatPhotoPhase.Preparing) + + @Test + fun overlayIsIndeterminateBeforeTheFirstBytes() = assertIndeterminate(ChatPhotoPhase.Uploading(0f)) + + @Test + fun overlayIsIndeterminateWhileProcessing() = assertIndeterminate(ChatPhotoPhase.Processing) + + @Test + fun overlayIsIndeterminateWhileSending() = assertIndeterminate(ChatPhotoPhase.Sending) + + private fun assertIndeterminate(phase: ChatPhotoPhase) { + setOverlay(phase) rule.onNodeWithTag(PHOTO_PROGRESS_TAG).assertIsDisplayed() - rule.onNodeWithTag(PHOTO_PROGRESS_FILL_TAG).assertDoesNotExist() + .assert(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)) + rule.onNodeWithTag(PHOTO_PROGRESS_FILL_TAG).assertExists() } @Test From ddc9937dc78d4aa79e19b758a3cda9b803669acb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 11:41:06 -0400 Subject: [PATCH 7/7] fix(chat-ui): keep a photo's upload fill moving and run it to full The fill eased to each byte report over 200 ms and then sat still until the next one, so a slow upload moved in steps. It now follows the reports linearly over 450 ms, longer than the gap between them, so it keeps moving. When the bytes finish it runs to the end of the track and holds briefly before the moving segment takes over, instead of cutting from part-way. --- .../chat/ui/media/ChatPhotoProgressOverlay.kt | 51 +++++++++++---- .../chat/ui/media/ChatPhotoMediaUiTest.kt | 62 +++++++++++++++++++ 2 files changed, 102 insertions(+), 11 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt index 96fe97b474..7706138ba9 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt @@ -1,6 +1,8 @@ package com.flipcash.shared.chat.ui.media +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState @@ -65,7 +67,10 @@ internal const val PHOTO_PROGRESS_TAG = "chat_photo_progress" internal const val PHOTO_PROGRESS_FILL_TAG = "chat_photo_progress_fill" private const val SEGMENT_FRACTION = 0.35f -private const val FRACTION_MILLIS = 200 +// Longer than the gap between progress reports, so the fill never stops between them. +private const val FRACTION_MILLIS = 450 +private const val FINISH_MILLIS = 300 +private const val FULL_HOLD_MILLIS = 150L private const val SENT_FADE_MILLIS = 250 private const val SLIDE_MILLIS = 1100 internal const val PHOTO_PROGRESS_MIN_VISIBLE_MILLIS = 600L @@ -74,7 +79,9 @@ internal const val PHOTO_PROGRESS_MIN_VISIBLE_MILLIS = 600L * The capsule at the bottom-end of a photo bubble that shows an upload's progress. Fill the * bubble's box with it; it positions itself. * - * [ChatPhotoPhase.Uploading] with bytes sent is a fill that follows the byte fraction. Every phase + * [ChatPhotoPhase.Uploading] with bytes sent is a fill that follows the byte fraction at a steady + * pace, trailing it slightly so it keeps moving between reports. When the bytes are done the fill + * runs to the end of the track before the next phase takes over. Every phase * without a byte count yet ([ChatPhotoPhase.Preparing], an upload before its first bytes, * [ChatPhotoPhase.Processing], [ChatPhotoPhase.Sending]) is a segment 35% of the track sliding * across it. [ChatPhotoPhase.Sent] fades the capsule out; [ChatPhotoPhase.Failed] removes it @@ -105,6 +112,33 @@ fun ChatPhotoProgressOverlay( if (phase == ChatPhotoPhase.Failed || (sent && alpha == 0f)) return val bytes = (phase as? ChatPhotoPhase.Uploading)?.fraction?.coerceIn(0f, 1f)?.takeIf { it > 0f } + val fill = remember { Animatable(0f) } + // True from the first bytes until the fill has run to the end of the track. + var filling by remember { mutableStateOf(false) } + LaunchedEffect(bytes, phase) { + when { + bytes != null -> { + filling = true + // A restarted upload starts over rather than shrinking the bar. + if (reduceMotion || bytes < fill.value) { + fill.snapTo(bytes) + } else { + fill.animateTo(bytes, tween(FRACTION_MILLIS, easing = LinearEasing)) + } + } + + filling -> { + if (reduceMotion) { + fill.snapTo(1f) + } else { + fill.animateTo(1f, tween(FINISH_MILLIS, easing = LinearEasing)) + } + // Let the full bar be seen before the next phase takes it over. + delay(FULL_HOLD_MILLIS) + filling = false + } + } + } val description = stringResource(R.string.description_chatPhotoUploadProgress) val grid = CodeTheme.dimens.staticGrid Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.BottomEnd) { @@ -121,20 +155,14 @@ fun ChatPhotoProgressOverlay( contentDescription = description progressBarRangeInfo = when { bytes != null -> ProgressBarRangeInfo(bytes, 0f..1f) + filling -> ProgressBarRangeInfo(1f, 0f..1f) phase == ChatPhotoPhase.Sent -> ProgressBarRangeInfo(1f, 0f..1f) else -> ProgressBarRangeInfo.Indeterminate } }, ) { when { - bytes != null -> { - val fraction by animateFloatAsState( - targetValue = bytes, - animationSpec = if (reduceMotion) snap() else tween(FRACTION_MILLIS), - label = "photoProgressFraction", - ) - Fill(widthFraction = { fraction }, offsetFraction = { 0f }) - } + bytes != null || filling -> Fill(widthFraction = { fill.value }, offsetFraction = { 0f }) // Hold a full bar while the capsule fades. phase == ChatPhotoPhase.Sent -> Fill(widthFraction = { 1f }, offsetFraction = { 0f }) @@ -169,7 +197,6 @@ private fun IndeterminateSegment(reduceMotion: Boolean) { private fun Fill(widthFraction: () -> Float, offsetFraction: () -> Float) { Box( modifier = Modifier - .testTag(PHOTO_PROGRESS_FILL_TAG) .layout { measurable, constraints -> // Read in layout so a moving segment re-lays-out without recomposing. val width = (constraints.maxWidth * widthFraction()).toInt().coerceAtLeast(0) @@ -180,6 +207,8 @@ private fun Fill(widthFraction: () -> Float, offsetFraction: () -> Float) { placeable.place((constraints.maxWidth * offsetFraction()).toInt(), 0) } } + // After the layout, so the node's bounds are the drawn fill rather than the track. + .testTag(PHOTO_PROGRESS_FILL_TAG) .fillMaxSize() .background(ChatMediaBlue), ) diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt index bfd1f8ae0e..01ca1007cb 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt @@ -104,6 +104,68 @@ class ChatPhotoMediaUiTest { } } + private fun setAnimatedOverlay(initial: ChatPhotoPhase): (ChatPhotoPhase) -> Unit { + var phase by mutableStateOf(initial) + rule.mainClock.autoAdvance = false + rule.setContent { + DesignSystem { + Box(Modifier.size(200.dp)) { ChatPhotoProgressOverlay(phase, reduceMotion = false) } + } + } + return { phase = it } + } + + private fun fillWidth(): Int = + rule.onNodeWithTag(PHOTO_PROGRESS_FILL_TAG, useUnmergedTree = true).fetchSemanticsNode().size.width + + private fun trackWidth(): Int = + rule.onNodeWithTag(PHOTO_PROGRESS_TAG, useUnmergedTree = true).fetchSemanticsNode().size.width + + @Test + fun byteFillKeepsMovingBetweenProgressReports() { + val setPhase = setAnimatedOverlay(ChatPhotoPhase.Uploading(0.2f)) + advanceFramesFor(1_000) + setPhase(ChatPhotoPhase.Uploading(0.5f)) + // A report every 300 ms: the fill must still be moving when the next one is due. + advanceFramesFor(200) + val at200 = fillWidth() + advanceFramesFor(100) + val at300 = fillWidth() + assertTrue(at300 > at200, "fill stalled between reports: $at200 -> $at300") + } + + @Test + fun byteFillRunsToFullBeforeTheBarTurnsIndeterminate() { + val setPhase = setAnimatedOverlay(ChatPhotoPhase.Uploading(0.6f)) + advanceFramesFor(1_000) + val beforeDone = fillWidth() + setPhase(ChatPhotoPhase.Processing) + advanceFramesFor(50) + rule.onNodeWithTag(PHOTO_PROGRESS_TAG) + .assert(hasProgressBarRangeInfo(ProgressBarRangeInfo(1f, 0f..1f))) + assertTrue(fillWidth() >= beforeDone, "fill went backwards: $beforeDone -> ${fillWidth()}") + + advanceFramesFor(1_000) + rule.onNodeWithTag(PHOTO_PROGRESS_TAG) + .assert(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)) + } + + @Test + fun byteFillReachesTheFullTrack() { + val setPhase = setAnimatedOverlay(ChatPhotoPhase.Uploading(0.6f)) + advanceFramesFor(1_000) + setPhase(ChatPhotoPhase.Processing) + var widest = 0 + repeat(40) { + advanceFramesFor(16) + if (rule.onNodeWithTag(PHOTO_PROGRESS_TAG).fetchSemanticsNode().config + .getOrElseNullable(androidx.compose.ui.semantics.SemanticsProperties.ProgressBarRangeInfo) { null } + != ProgressBarRangeInfo.Indeterminate + ) widest = maxOf(widest, fillWidth()) + } + assertEquals(trackWidth(), widest) + } + @Test fun overlayHidesImmediatelyWhenFailed() { setOverlay(ChatPhotoPhase.Failed)