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 91894db4e8..9c14ab37b0 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 @@ -327,6 +327,9 @@ sealed interface AppRoute : NavKey, Parcelable { // Open straight into composing a reply with the keyboard up. Only the post-tip // hand-off (see TipCardDecorator) sets this; normal opens default to keyboard-closed. val openKeyboard: Boolean = false, + // Open with send cash already started: the fee sheet if this payment opens the DM, the + // keypad if the DM exists. Only a profile's "Send Cash" shortcut sets this. + val openSendCash: Boolean = false, ) : Messaging, FlowRoute { override val initialStack: List get() = listOf(ChatStep.Conversation) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 6ab9ccce95..09e4e44abf 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1197,6 +1197,9 @@ No One Blocked Block people from sending you messages by tapping their profile and selecting block Joined %1$s + + Message Something Went Wrong We were unable to block the user. Please try again diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 1ca0210814..cec34f2cb9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -5,6 +5,7 @@ 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -20,6 +21,7 @@ import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.StartSendCashOnceReady import com.flipcash.app.messenger.internal.screens.GroupInviteSheet import com.flipcash.app.messenger.internal.screens.MessengerScreen import com.flipcash.app.messenger.internal.screens.MuteChatSheet @@ -46,8 +48,10 @@ import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher import com.getcode.navigation.scenes.LocalSheetNavigator import com.getcode.navigation.scenes.ModalBottomSheetSceneStrategy import com.getcode.ui.utils.rememberKeyboardController +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @Composable @@ -66,7 +70,7 @@ fun ChatFlowScreen( // onRootReached, and so does system back — so this is the one place that has to do it. // Popping with the IME still up drags the screen behind it out from under the keyboard. onExit = { _, _ -> keyboard.hideIfVisible { navigator.pop() } }, - entryProvider = chatEntryProvider(route.identifier, route.openKeyboard), + entryProvider = chatEntryProvider(route.identifier, route.openKeyboard, route.openSendCash), // ChatStep.AmountEntry, ChatStep.InitPayment, ChatStep.InviteToGroup and // ChatStep.MuteChat are Sheets, so the // flow needs the sheet strategy to draw them as such; without it the step would fall @@ -85,9 +89,10 @@ fun ChatFlowScreen( private fun chatEntryProvider( identifier: ChatIdentifier, openKeyboard: Boolean, + openSendCash: Boolean, ): (NavKey) -> NavEntry = entryProvider { annotatedEntry { - FlowConversationScreen(identifier, openKeyboard) + FlowConversationScreen(identifier, openKeyboard, openSendCash) } annotatedEntry { FlowAmountEntryScreen() @@ -120,7 +125,11 @@ private fun chatEntryProvider( } @Composable -private fun FlowConversationScreen(identifier: ChatIdentifier, openKeyboard: Boolean) { +private fun FlowConversationScreen( + identifier: ChatIdentifier, + openKeyboard: Boolean, + openSendCash: Boolean, +) { val viewModel = flowSharedViewModel() val navigator = LocalCodeNavigator.current // The sheet-owning (root) navigator — the one whose back stack holds this chat's Main.Sheet and @@ -190,6 +199,16 @@ private fun FlowConversationScreen(identifier: ChatIdentifier, openKeyboard: Boo } } + // After the collectors above, so the step OnSendCash navigates to has someone listening. + // Not keyed on first composition the way openKeyboard is: the handler drops the event until + // the participant is set, and picks the wrong step until the fee is known. + val sendCashReady by remember(viewModel) { + viewModel.stateFlow.map { it.sendCashReady }.distinctUntilChanged() + }.collectAsStateWithLifecycle(initialValue = false) + StartSendCashOnceReady(requested = openSendCash, ready = sendCashReady) { + viewModel.dispatchEvent(ChatViewModel.Event.OnSendCash) + } + MessengerScreen(viewModel) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt index e99ae070d7..a790708054 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt @@ -51,11 +51,15 @@ import com.getcode.theme.CodeTheme * Takes the [ViewerState] rather than a label so the countdown behind it is this composable's — * see [rememberMutedLabel], which drops the text the moment a timed mute lapses with nothing sent * from the server to say so. + * + * [reserveSpace] false drops the held line, for a header where something else ends the block and + * an empty line would only read as a gap. */ @Composable internal fun ChatMuteStatusChip( viewerState: ViewerState?, modifier: Modifier = Modifier, + reserveSpace: Boolean = true, ) { val label = rememberMutedLabel(viewerState) @@ -63,12 +67,14 @@ internal fun ChatMuteStatusChip( // Holds the line's height whether or not there is a mute, so the rows below don't move the // moment one lapses or is cleared — that reads as the screen re-laying itself out rather // than one fact going away. Never drawn, never read aloud. - Chip( - label = stringResource(R.string.label_muted), - modifier = Modifier - .alpha(0f) - .clearAndSetSemantics { }, - ) + if (reserveSpace) { + Chip( + label = stringResource(R.string.label_muted), + modifier = Modifier + .alpha(0f) + .clearAndSetSemantics { }, + ) + } // Springs in and fades out. Arriving is the event worth seeing — the user just chose it — // so it gets the overshoot; going away is either their unmute or a deadline passing, and 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 22c20b7af5..ce6096a0b4 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 @@ -214,6 +214,14 @@ internal class ChatViewModel @Inject constructor( * value: both mean the pill falls back to its unpriced label. */ val chatInitFee: String? = null, + /** + * Whether [Event.OnSendCash] would now take the path it will keep: the fee sheet for the + * payment that opens a tip DM, the keypad for any other. See [isSendCashReady]. + * + * Only a chat opened with send cash already started reads it. [chatInitFee] can't answer + * this, because it is null both before the fee resolves and when there is no fee at all. + */ + val sendCashReady: Boolean = false, // Transient "focus the message input" request. Set by OnStartMessageInput (dispatched when // returning from amount entry after a send, and on a post-tip chat open) and cleared by // OnMessageInputConsumed once the bottom bar has focused the field and shown the keyboard. @@ -413,6 +421,7 @@ internal class ChatViewModel @Inject constructor( data object UnmuteChat : Event data class OnCurrencySymbolUpdated(val symbol: String): Event data class OnChatInitFeeUpdated(val formatted: String?) : Event + data class OnSendCashReadinessChanged(val ready: Boolean) : Event data object RefreshContact : Event data class ChatFound(val chatId: ChatId) : Event data object OnSendCash: Event @@ -802,14 +811,22 @@ internal class ChatViewModel @Inject constructor( /** * Whether this conversation already exists. Members are the same signal * [com.flipcash.shared.chat.DmChatResolver.getChatId] calls initialized: a chat the server has - * created has a member row, one derived from a user id alone does not. + * created has a member row, one derived from a user id alone does not. Null until the member + * store has answered; [isChatInitialized] reads that as false. */ @OptIn(ExperimentalCoroutinesApi::class) - private val isChatInitialized by lazy { + private val chatExistence by lazy { stateFlow.mapNotNull { it.chatId } .distinctUntilChanged() .flatMapLatest { chatCoordinator.observeMembers(it) } .map { it.isNotEmpty() } + .distinctUntilChanged() + // Null until the member store has answered, which is not the same as "doesn't exist". + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + } + + private val isChatInitialized by lazy { + chatExistence.map { it == true } .distinctUntilChanged() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) } @@ -1283,6 +1300,15 @@ internal class ChatViewModel @Inject constructor( .onEach { dispatchEvent(Event.OnChatInitFeeUpdated(it?.formatted())) } .launchIn(viewModelScope) + combine( + stateFlow.map { it.participant }.distinctUntilChanged(), + chatExistence, + minAmountFlow, + ) { participant, exists, fee -> isSendCashReady(participant, exists, fee) } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnSendCashReadinessChanged(it)) } + .launchIn(viewModelScope) + transactionController.limits .onEach { dispatchEvent(Event.LimitsChanged(it)) } .launchIn(viewModelScope) @@ -2121,6 +2147,7 @@ internal class ChatViewModel @Inject constructor( } is Event.OnCurrencySymbolUpdated -> { state -> state.copy(cashSymbol = event.symbol) } is Event.OnChatInitFeeUpdated -> { state -> state.copy(chatInitFee = event.formatted) } + is Event.OnSendCashReadinessChanged -> { state -> state.copy(sendCashReady = event.ready) } is Event.RefreshContact -> { state -> state } is Event.ChatFound -> { state -> state.copy(chatId = event.chatId) } Event.OnSendCash -> { state -> state } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SendCashOnOpen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SendCashOnOpen.kt new file mode 100644 index 0000000000..829fd607ab --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SendCashOnOpen.kt @@ -0,0 +1,62 @@ +package com.flipcash.app.messenger.internal + +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.flipcash.app.core.chat.ChatParticipant +import com.getcode.opencode.model.financial.Fiat + +/** + * Whether [ChatViewModel.Event.OnSendCash] would now pick the step it will keep picking. + * + * The handler drops the event while there is no participant, and otherwise branches on the fee to + * open the chat: the fee sheet when there is one, the keypad when there isn't. Before the fee and + * the chat's existence are both known, that branch can land on the keypad for a payment that + * should have been the fee sheet. So a tip DM is ready only once the two agree: + * - the chat exists and there is no opening fee, or + * - the chat doesn't exist and the fee has resolved. + * + * [chatExists] is null until the member store has answered. A contact DM never has a fee, so it is + * ready as soon as it has a participant. + */ +internal fun isSendCashReady( + participant: ChatParticipant?, + chatExists: Boolean?, + openingFee: Fiat?, +): Boolean = when (participant) { + null -> false + is ChatParticipant.Contact -> true + is ChatParticipant.TipUser -> when (chatExists) { + null -> false + true -> openingFee == null + false -> openingFee != null + } +} + +/** + * Calls [onStart] once, the first time [ready] is true while [requested] is. + * + * The flag is saved, so recomposition, rotation, process death, and coming back from the step + * [onStart] opened don't call it again. + */ +@VisibleForTesting +@Composable +internal fun StartSendCashOnceReady( + requested: Boolean, + ready: Boolean, + onStart: () -> Unit, +) { + var started by rememberSaveable { mutableStateOf(false) } + val currentOnStart by rememberUpdatedState(onStart) + LaunchedEffect(requested, ready) { + if (requested && ready && !started) { + started = true + currentOnStart() + } + } +} 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 b0de4a28ef..f9fb8d4429 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 @@ -45,6 +45,12 @@ import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.HazeBlurStyle import dev.chrisbanes.haze.blur.hazeBlur +/** + * The currency symbol's size once the button has condensed to the symbol alone. The profile's Send + * Cash shortcut draws its symbol at this size too, so the two read as the same control. + */ +internal val CondensedSymbolFontSize = 22.sp + @Composable internal fun RowScope.SendCashButton( state: ChatViewModel.State, @@ -108,7 +114,7 @@ internal fun RowScope.SendCashButton( // any typography change. val symbolFontSize by animateFloatAsState( targetValue = if (isTyping) { - 22.sp.value + CondensedSymbolFontSize.value } else { CodeTheme.typography.textMedium.fontSize.value }, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index 59bcf44b0a..fe1a717f2e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.screens.profile import android.os.Parcelable import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -18,7 +19,9 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.chat.ReportSubject @@ -56,6 +59,10 @@ import kotlin.time.Instant * and there the chat behind it is the group — a mute row on a member's profile would silence the * whole group from a screen that names one person. Contact DMs never reach this screen * ([com.flipcash.app.messenger.internal.ChatSubject.Contact] answers `canViewProfile` false). + * + * The Message and Send Cash shortcuts follow the same split, the other way round: they show on a + * group member's profile and not on a tip DM's, where they would only reopen the chat behind it. + * [profileShortcutRecipient] has the whole rule. */ @Composable internal fun ChatProfileScreen( @@ -87,10 +94,26 @@ internal fun ChatProfileScreen( add(BlockUser) }, header = { + val recipient = profileShortcutRecipient( + participant = state.participant, + chatType = chatState.chatType, + selfId = state.selfId, + ) ProfileHeader( participant = state.participant, joinDate = state.joinDate, viewerState = chatState.viewerState, + // Not flowNavigator, for the reason Report isn't: the DM is a top-level route, + // so LocalCodeNavigator hands it up and it opens over this chat. + shortcuts = recipient?.let { user -> + { + ProfileShortcuts( + cashSymbol = chatState.cashSymbol, + onMessage = { navigator.push(user.dmRoute()) }, + onSendCash = { navigator.push(user.dmRoute(openSendCash = true)) }, + ) + } + }, modifier = Modifier .fillMaxWidth() // The gap under the header is the header's own, because MenuList puts @@ -100,7 +123,14 @@ internal fun ChatProfileScreen( // screen reads as one block rather than a title above a list. .padding( top = CodeTheme.dimens.grid.x7, - bottom = CodeTheme.dimens.grid.x8, + // None under the shortcuts: the first row's own 25dp inset is the + // gap, and ProfileHeader matches it above them so they sit centered + // between the join date and the list. + bottom = if (recipient != null) { + 0.dp + } else { + CodeTheme.dimens.grid.x8 + }, ), ) }, @@ -152,6 +182,7 @@ internal fun ProfileHeader( joinDate: Instant?, modifier: Modifier = Modifier, viewerState: ViewerState? = null, + shortcuts: (@Composable () -> Unit)? = null, ) { Column( modifier = modifier, @@ -202,6 +233,23 @@ internal fun ProfileHeader( ChatMuteStatusChip( viewerState = viewerState, modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + // With the shortcuts below, an empty held line would double the gap above them. This + // screen only shows them for a group member, where there's no mute row to change it. + reserveSpace = shortcuts == null, ) + // Below everything that describes the person: these act on them, like the rows under the + // header, but they're the routine ones, so they sit closest to the name. 15dp here plus + // the mute line's 10dp is 25dp, the same as the first row's inset below them. + shortcuts?.let { content -> + Box(modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3)) { + content() + } + } } } + +private fun ChatParticipant.TipUser.dmRoute(openSendCash: Boolean = false) = + AppRoute.Messaging.Chat( + identifier = ChatIdentifier.ByUser(userId, profile), + openSendCash = openSendCash, + ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt index 285cdb1b53..2c3be22505 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt @@ -11,6 +11,7 @@ import com.flipcash.services.controllers.ProfileController import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager +import com.getcode.opencode.model.core.ID import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import com.getcode.view.LoadingSuccessState @@ -36,7 +37,7 @@ internal class ChatProfileViewModel @Inject constructor( private val dispatchers: DispatcherProvider, private val resources: ResourceHelper, ) : BaseViewModel( - initialState = State(), + initialState = State(selfId = userManager.accountId), updateStateForEvent = updateStateForEvent, defaultDispatcher = dispatchers.Default, ) { @@ -44,6 +45,8 @@ internal class ChatProfileViewModel @Inject constructor( val participant: ChatParticipant? = null, val joinDate: Instant? = null, val processingState: LoadingSuccessState = LoadingSuccessState(), + /** The viewer's own user id, so the screen can tell their own profile from someone else's. */ + val selfId: ID? = null, ) sealed interface Event { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt new file mode 100644 index 0000000000..8157d37ead --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt @@ -0,0 +1,146 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import com.flipcash.app.core.chat.ChatParticipant +// Used by the held-back Send Cash shortcut below. +// import com.flipcash.app.messenger.internal.screens.components.CondensedSymbolFontSize +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatType +import com.getcode.opencode.model.core.ID +import com.getcode.theme.CodeTheme + +/** + * Who the profile's Message and Send Cash shortcuts would open a DM with, or null to leave them + * out. + * + * - Not from a tip DM: a DM's profile is always the other person in it, so the shortcuts would + * only reopen the chat the viewer came from. + * - Not for the viewer's own profile: there is no DM with yourself. + * - Only for a [ChatParticipant.TipUser]: the DM route is addressed by user id, which a contact + * doesn't have. Contact DMs don't reach this screen anyway. + * + * Blocking isn't checked. The chat the shortcut opens handles a blocked user the way any other + * entry to it does. + */ +internal fun profileShortcutRecipient( + participant: ChatParticipant?, + chatType: ChatType, + selfId: ID?, +): ChatParticipant.TipUser? { + if (chatType == ChatType.TIP_DM) return null + val user = participant as? ChatParticipant.TipUser ?: return null + if (selfId != null && user.userId == selfId) return null + return user +} + +/** + * The Message and Send Cash shortcuts under a profile's identity lines. + * + * Two fixed-width columns, so the pair stays centered and the circles stay the same distance apart + * whatever the labels say. The Send Cash glyph is [cashSymbol] rather than an icon, so it names the + * currency the send will be in, at the size the chat's condensed send button draws it. + */ +@Composable +internal fun ProfileShortcuts( + cashSymbol: String, + onMessage: () -> Unit, + onSendCash: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x3), + ) { + ProfileShortcut( + label = stringResource(R.string.action_message), + onClick = onMessage, + ) { + Icon( + painter = painterResource(R.drawable.ic_chat_bubble), + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), + ) + } + // Send Cash is held back for now. The route flag and the chat's once-ready dispatch behind + // it stay wired, so bringing it back is uncommenting this. + // ProfileShortcut( + // label = stringResource(R.string.action_sendCash), + // onClick = onSendCash, + // ) { + // Text( + // text = cashSymbol, + // color = CodeTheme.colors.textMain, + // style = CodeTheme.typography.textMedium.copy(fontSize = CondensedSymbolFontSize), + // maxLines = 1, + // softWrap = false, + // ) + // } + } +} + +@Composable +private fun ProfileShortcut( + label: String, + onClick: () -> Unit, + glyph: @Composable () -> Unit, +) { + // The whole column takes the tap, so the label is as much a target as the circle, but only the + // circle shows the ripple. + val interactionSource = remember { MutableInteractionSource() } + Column( + modifier = Modifier + .width(CodeTheme.dimens.staticGrid.x20) + .clickable( + interactionSource = interactionSource, + indication = null, + role = Role.Button, + onClick = onClick, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(CodeTheme.dimens.staticGrid.x9) + .clip(CircleShape) + .background(CodeTheme.colors.surfaceVariant) + .indication(interactionSource, ripple()), + contentAlignment = Alignment.Center, + ) { + glyph() + } + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.staticGrid.x1), + text = label, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/apps/flipcash/features/messenger/src/main/res/drawable/ic_chat_bubble.xml b/apps/flipcash/features/messenger/src/main/res/drawable/ic_chat_bubble.xml new file mode 100644 index 0000000000..e44defc076 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/res/drawable/ic_chat_bubble.xml @@ -0,0 +1,11 @@ + + + + diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt index 035c8278c0..e5d2ba1188 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt @@ -16,6 +16,7 @@ import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.messenger.internal.screens.components.ChatTopBar import com.flipcash.app.messenger.internal.screens.components.ChatInfoCard import com.flipcash.app.messenger.internal.screens.profile.ProfileHeader +import com.flipcash.app.messenger.internal.screens.profile.ProfileShortcuts import com.flipcash.app.theme.FlipcashPreview import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatType @@ -159,6 +160,39 @@ class ChatIdentityScreenshotTest { capture("chat_profile_header_identity.png") } + @Test + fun rendersProfileHeaderShortcuts() { + val joinDate = Instant.fromEpochMilliseconds(1_700_000_000_000) + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + // Dollar and euro, so a symbol change is visible in the glyph. + listOf("$", "€").forEach { symbol -> + ProfileHeader( + participant = namedTipUser, + joinDate = joinDate, + modifier = Modifier.width(300.dp), + shortcuts = { + ProfileShortcuts( + cashSymbol = symbol, + onMessage = {}, + onSendCash = {}, + ) + }, + ) + } + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("chat_profile_header_shortcuts.png") + } + private fun capture(name: String) { val root: View = composeRule.activity.findViewById(android.R.id.content) val width = root.width.takeIf { it > 0 } ?: 1080 diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt index 8d8f7105c5..c05a3a5a07 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt @@ -362,6 +362,49 @@ class ChatViewModelStateTest { assertTrue(sent.draftSnapshot().isEmpty) } + private val tipUser = ChatParticipant.TipUser( + userId = listOf(9.toByte()), + profile = UserProfile.Empty.copy(username = "grace_hopper"), + ) + + /** + * Send cash opened from a profile fires on the first answer that won't change, so none of + * these may say ready: without a participant the handler drops the event, and without the + * chat's existence or its fee it can pick the keypad for a payment that owes the fee. + */ + @Test + fun `send cash is not ready while the fee state is unknown`() { + assertFalse(isSendCashReady(participant = null, chatExists = false, openingFee = Fiat(1.0))) + assertFalse(isSendCashReady(tipUser, chatExists = null, openingFee = null)) + assertFalse(isSendCashReady(tipUser, chatExists = null, openingFee = Fiat(1.0))) + // Doesn't exist yet, and the fee to open it hasn't resolved. + assertFalse(isSendCashReady(tipUser, chatExists = false, openingFee = null)) + // Exists, but the fee computed before the member store answered is still standing. + assertFalse(isSendCashReady(tipUser, chatExists = true, openingFee = Fiat(1.0))) + } + + @Test + fun `send cash is ready once the fee and the chat agree`() { + // A new DM: the fee sheet. + assertTrue(isSendCashReady(tipUser, chatExists = false, openingFee = Fiat(1.0))) + // An existing DM: the keypad, with no fee. + assertTrue(isSendCashReady(tipUser, chatExists = true, openingFee = null)) + } + + @Test + fun `a contact DM is ready to send cash as soon as it has a participant`() { + assertTrue(isSendCashReady(dm.participant, chatExists = null, openingFee = null)) + } + + @Test + fun `the reducer holds send cash readiness`() { + val ready = ChatViewModel.updateStateForEvent( + ChatViewModel.Event.OnSendCashReadinessChanged(true) + )(ChatViewModel.State()) + assertTrue(ready.sendCashReady) + assertFalse(ChatViewModel.State().sendCashReady) + } + /** What the ViewModel's own `draftSnapshot` composes, over a state the reducer produced. */ private fun ChatViewModel.State.draftSnapshot(): ChatDraftSnapshot = chatDraftOf( composerText = chatInputState.text.toString(), diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/StartSendCashOnceReadyTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/StartSendCashOnceReadyTest.kt new file mode 100644 index 0000000000..d735f0bc7c --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/StartSendCashOnceReadyTest.kt @@ -0,0 +1,73 @@ +package com.flipcash.app.messenger.internal + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals + +/** + * The chat opened by a profile's Send Cash starts the send once, when the fee state is known, and + * never again for that chat: not when readiness flickers, and not when the screen is restored. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class StartSendCashOnceReadyTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `waits for readiness, then starts once`() { + var ready by mutableStateOf(false) + var starts = 0 + composeRule.setContent { + StartSendCashOnceReady(requested = true, ready = ready) { starts++ } + } + composeRule.waitForIdle() + assertEquals(0, starts) + + ready = true + composeRule.waitForIdle() + assertEquals(1, starts) + + // The participant or fee changing under an open chat, then settling again. + ready = false + composeRule.waitForIdle() + ready = true + composeRule.waitForIdle() + assertEquals(1, starts) + } + + @Test + fun `a restored screen doesn't start it again`() { + val restoration = StateRestorationTester(composeRule) + var starts = 0 + restoration.setContent { + StartSendCashOnceReady(requested = true, ready = true) { starts++ } + } + composeRule.waitForIdle() + assertEquals(1, starts) + + restoration.emulateSavedInstanceStateRestore() + composeRule.waitForIdle() + assertEquals(1, starts) + } + + @Test + fun `an ordinary open never starts it`() { + var starts = 0 + composeRule.setContent { + StartSendCashOnceReady(requested = false, ready = true) { starts++ } + } + composeRule.waitForIdle() + assertEquals(0, starts) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcutRecipientTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcutRecipientTest.kt new file mode 100644 index 0000000000..104dbd5f69 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcutRecipientTest.kt @@ -0,0 +1,50 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatType +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** When a profile offers Message and Send Cash. Kept in step with the iOS profile's rule. */ +class ProfileShortcutRecipientTest { + + private val self = listOf(1.toByte()) + private val member = ChatParticipant.TipUser( + userId = listOf(2.toByte()), + profile = UserProfile.Empty.copy(username = "grace_hopper"), + ) + + @Test + fun `a group member's profile offers the shortcuts`() { + assertEquals(member, profileShortcutRecipient(member, ChatType.GROUP, self)) + } + + @Test + fun `a tip DM's profile doesn't, because they would reopen the chat behind it`() { + assertNull(profileShortcutRecipient(member, ChatType.TIP_DM, self)) + } + + @Test + fun `the viewer's own profile doesn't`() { + val me = member.copy(userId = self) + assertNull(profileShortcutRecipient(me, ChatType.GROUP, self)) + } + + @Test + fun `a contact or a missing participant doesn't`() { + val contact = ChatParticipant.Contact( + DeviceContact( + e164 = "+15551234567", + androidContactId = 1L, + displayName = "Ada Lovelace", + photoUri = null, + displayNumber = "(555) 123-4567", + ) + ) + assertNull(profileShortcutRecipient(contact, ChatType.GROUP, self)) + assertNull(profileShortcutRecipient(null, ChatType.GROUP, self)) + } +}