Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<NavKey>
get() = listOf(ChatStep.Conversation)
Expand Down
3 changes: 3 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1197,6 +1197,9 @@
<string name="title_blocklistEmpty">No One Blocked</string>
<string name="description_blocklistEmpty">Block people from sending you messages by tapping their profile and selecting block</string>
<string name="subtitle_joinedDate">Joined %1$s</string>
<!-- The profile's two shortcuts into the DM with this person. "Send Cash" beside it is
action_sendCash. -->
<string name="action_message">Message</string>

<string name="error_title_failedToBlock">Something Went Wrong</string>
<string name="error_description_failedToBlock">We were unable to block the user. Please try again</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -85,9 +89,10 @@ fun ChatFlowScreen(
private fun chatEntryProvider(
identifier: ChatIdentifier,
openKeyboard: Boolean,
openSendCash: Boolean,
): (NavKey) -> NavEntry<NavKey> = entryProvider {
annotatedEntry<ChatStep.Conversation> {
FlowConversationScreen(identifier, openKeyboard)
FlowConversationScreen(identifier, openKeyboard, openSendCash)
}
annotatedEntry<ChatStep.AmountEntry> {
FlowAmountEntryScreen()
Expand Down Expand Up @@ -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<ChatViewModel>()
val navigator = LocalCodeNavigator.current
// The sheet-owning (root) navigator — the one whose back stack holds this chat's Main.Sheet and
Expand Down Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,24 +51,30 @@ 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)

Box(modifier = modifier, contentAlignment = Alignment.Center) {
// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Boolean?>(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
}

private val isChatInitialized by lazy {
chatExistence.map { it == true }
.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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
},
),
)
},
Expand Down Expand Up @@ -152,6 +182,7 @@ internal fun ProfileHeader(
joinDate: Instant?,
modifier: Modifier = Modifier,
viewerState: ViewerState? = null,
shortcuts: (@Composable () -> Unit)? = null,
) {
Column(
modifier = modifier,
Expand Down Expand Up @@ -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,
)
Loading
Loading