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..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 @@ -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,26 @@ 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 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] 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 = + (hasText || composerPhotos.isNotEmpty()) && !hasFailedPhoto + /** * What a tap on a cash card in this transcript does. * @@ -631,6 +674,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 +883,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 +973,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 +989,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 +1108,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 +1203,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 +1229,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 +1241,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 +1377,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 +1398,7 @@ internal class ChatViewModel @Inject constructor( initTokenAndExchangeObservers() initTypingHandlers() initSendHandlers() + initPhotoHandlers() initMentionPicker() initMessageActionHandlers() } @@ -2045,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) @@ -2341,13 +2447,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 +2537,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 +2580,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 +2839,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 +2857,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 +3118,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..6dda753694 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatComposerPhotosTest.kt @@ -0,0 +1,104 @@ +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 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)) + } + + @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/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) } + } +} 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..2975ffb922 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/AttachSurface.kt @@ -0,0 +1,1105 @@ +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.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 +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 + // 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. + 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..d376296c16 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatCameraPanel.kt @@ -0,0 +1,543 @@ +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(ChatMediaBlue, 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) + +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..7706138ba9 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoProgressOverlay.kt @@ -0,0 +1,236 @@ +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 +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.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 +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 +import kotlinx.coroutines.delay + +/** 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 +// 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 + +/** + * 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 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 + * 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(), +) { + // 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), + label = "photoProgressAlpha", + ) + 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) { + 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 + 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 || filling -> Fill(widthFraction = { fill.value }, offsetFraction = { 0f }) + + // Hold a full bar while the capsule fades. + phase == ChatPhotoPhase.Sent -> Fill(widthFraction = { 1f }, offsetFraction = { 0f }) + + else -> IndeterminateSegment(reduceMotion) + } + } + } +} + +@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 + .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) + } + } + // After the layout, so the node's bounds are the drawn fill rather than the track. + .testTag(PHOTO_PROGRESS_FILL_TAG) + .fillMaxSize() + .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 +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..01ca1007cb --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt @@ -0,0 +1,331 @@ +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.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 +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 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 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() + .assert(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)) + rule.onNodeWithTag(PHOTO_PROGRESS_FILL_TAG).assertExists() + } + + @Test + fun overlayHidesWhenSent() { + setOverlay(ChatPhotoPhase.Sent) + 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() + } + } + + 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) + 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." + } + ] +}