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 @@ -122,6 +122,12 @@ internal class ChatViewModel @Inject constructor(
val separatorConfig: SeparatorConfig= SeparatorConfig.Continuous(),
val chatId: ChatId? = null,
val participant: ChatParticipant? = null,
// The kind of DM this conversation is, resolved from the fast local contact lookup ahead of
// the participant's server profile (which resolves over the network for tip DMs). Starts
// UNKNOWN and settles to CONTACT_DM / TIP_DM as soon as the chat opens; the send button and
// bottom bar read it to render the correct (condensed vs expanded) presentation immediately
// instead of flashing the expanded white pill while a tip profile loads.
val chatType: ChatType = ChatType.UNKNOWN,
val chatInputState: TextFieldState = TextFieldState(),
val typists: Set<ActiveTypist> = emptySet(),
val resolveState: ResolveState = ResolveState.Pending,
Expand All @@ -145,6 +151,7 @@ internal class ChatViewModel @Inject constructor(
data class OnChatOpened(val identifier: ChatIdentifier) : Event
data class OnContactFound(val contact: DeviceContact): Event
data class OnTipUserResolved(val userId: ID, val profile: UserProfile): Event
data object OnTipDmDetected : Event
data class OnCurrencySymbolUpdated(val symbol: String): Event
data object RefreshContact : Event
data class ChatFound(val chatId: ChatId) : Event
Expand Down Expand Up @@ -264,14 +271,6 @@ internal class ChatViewModel @Inject constructor(
.map { it.participant is ChatParticipant.TipUser }
.distinctUntilChanged()

/** The [ChatType] backing this conversation, derived from the resolved participant. */
private val ChatParticipant?.chatType: ChatType
get() = when (this) {
is ChatParticipant.TipUser -> ChatType.TIP_DM
is ChatParticipant.Contact -> ChatType.CONTACT_DM
null -> ChatType.UNKNOWN
}

private val amountStyleFlow by lazy {
isTipFlow
.map { amountStyle(isTip = it) }
Expand Down Expand Up @@ -362,11 +361,14 @@ internal class ChatViewModel @Inject constructor(
if (contact != null) {
dispatchEvent(Event.OnContactFound(contact))
} else {
// No device contact backs this chat — it's a tip DM. Warm the member
// store (fetch + persist if nothing is cached) so the reactive
// tip-identity collector can resolve the counterparty from their server
// profile. Identity is set reactively (see initChatHandlers), not here,
// so it can't be missed by a fast tap on "Send $".
// No device contact backs this chat — it's a tip DM. Mark it immediately
// (this lookup is local) so the send button renders condensed without
// waiting on the profile below, then warm the member store (fetch +
// persist if nothing is cached) so the reactive tip-identity collector
// can resolve the counterparty from their server profile. Identity is set
// reactively (see initChatHandlers), not here, so it can't be missed by a
// fast tap on "Send $".
dispatchEvent(Event.OnTipDmDetected)
viewModelScope.launch { chatCoordinator.getOtherMember(identifier.chatId) }
}
}
Expand Down Expand Up @@ -558,7 +560,7 @@ internal class ChatViewModel @Inject constructor(
val textToSend = stateFlow.value.chatInputState.text.toString()
val chatId = stateFlow.value.chatId ?: return@onEach
if (textToSend.isBlank()) return@onEach
val chatType = stateFlow.value.participant.chatType
val chatType = stateFlow.value.chatType

stateFlow.value.chatInputState.setTextAndPlaceCursorAtEnd("")

Expand Down Expand Up @@ -831,13 +833,21 @@ internal class ChatViewModel @Inject constructor(
when (event) {
is Event.OnChatOpened -> { state ->
when (val id = event.identifier) {
is ChatIdentifier.ByContact -> state.copy(participant = ChatParticipant.Contact(id.contact))
is ChatIdentifier.ByContact ->
state.copy(
participant = ChatParticipant.Contact(id.contact),
chatType = ChatType.CONTACT_DM,
)
is ChatIdentifier.ByChatId -> state
}
}
is Event.OnContactFound -> { state ->
state.copy(participant = ChatParticipant.Contact(event.contact))
state.copy(
participant = ChatParticipant.Contact(event.contact),
chatType = ChatType.CONTACT_DM,
)
}
Event.OnTipDmDetected -> { state -> state.copy(chatType = ChatType.TIP_DM) }
is Event.OnTipUserResolved -> { state ->
// A device contact, once matched, wins over the server profile (it carries the
// phone number and the user's own naming). Otherwise this is a tip DM: adopt the
Expand All @@ -846,6 +856,7 @@ internal class ChatViewModel @Inject constructor(
if (state.participant is ChatParticipant.Contact) state
else state.copy(
participant = ChatParticipant.TipUser(event.userId, event.profile),
chatType = ChatType.TIP_DM,
resolveState = ResolveState.Resolved,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.app.messenger.internal.screens.ChatAnimations
import com.flipcash.services.models.chat.ChatType
import com.flipcash.features.messenger.R
import com.getcode.theme.CodeTheme
import com.getcode.ui.components.chat.ChatInput
Expand Down Expand Up @@ -124,7 +125,19 @@ internal fun UserControlBottomBar(
// scaled down to the pill + input box. Hold the bar invisible (but measured, so
// the message list keeps correct padding) until resolved, then reveal the final
// layout directly — no visible full-width state, no resize.
.alpha(if (state.typingConstraints.resolved) 1f else 0f),
//
// The chat kind starts UNKNOWN and, for tip DMs, SendCashButton would otherwise
// read a not-yet-resolved chat as a non-tip chat and show the white expanded pill
// before condensing — a visible flash. Wait until the kind is known (chatType is
// CONTACT_DM or TIP_DM) so the bar reveals already in its final presentation.
// chatType resolves from a local contact lookup, not the network profile, so this
// adds no perceptible delay; a tip chat whose identity never resolves flips to the
// deactivated bar instead, so this can't hide it forever.
.alpha(
if (state.typingConstraints.resolved &&
state.chatType != ChatType.UNKNOWN
) 1f else 0f
),
targetState = state.typingConstraints.enabled,
// The layout only ever changes on the initial async resolution, which is hidden by
// the alpha gate above, so snap rather than crossfade. The SendCashButton's own
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,7 @@ private fun Indicator(
onClick = onClick
)

is ChatParticipant.TipUser -> TipPill(modifier = modifier)
null -> Spacer(modifier = modifier.fillMaxWidth())
else -> Spacer(modifier = modifier.fillMaxWidth())
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package com.flipcash.app.messenger.internal.screens.components

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
Expand All @@ -19,17 +22,21 @@ import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
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.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.flipcash.app.messenger.internal.ChatParticipant
import androidx.compose.ui.unit.sp
import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.services.models.chat.ChatType
import com.flipcash.features.messenger.R
import com.getcode.theme.CodeTheme
import com.getcode.ui.core.addIf
Expand All @@ -47,13 +54,22 @@ internal fun RowScope.SendCashButton(
) {
// Tip chats always use the minimized (dark, symbol-only) button. The normal send flow keeps the
// expanded "Send $" presentation and only collapses to the symbol once the user starts typing.
val isTipChat = state.participant is ChatParticipant.TipUser
// chatType resolves from the fast local contact lookup, so a tip DM condenses immediately rather
// than waiting on the server profile.
val isTipChat = state.chatType == ChatType.TIP_DM
val isTyping = isTipChat || state.chatInputState.text.isNotEmpty()
val canType = state.typingConstraints.enabled

// Colors ease slowly and independently of the width/label so the fill change reads as one
// calm transition instead of snapping with the resize.
val colorSpec = tween<Color>(durationMillis = 350)
// Colors ease slowly and independently of the width/label so the fill change reads as one calm
// transition instead of snapping with the resize — but NOT on the first settle. A tip chat opens
// before its kind is known, briefly reading as a non-tip chat (white); easing that initial commit
// would fade white→transparent in view. Snap until the kind resolves and the button commits its
// first appearance, then ease subsequent typing toggles.
val kindResolved = state.chatType != ChatType.UNKNOWN
var hasSettled by remember { mutableStateOf(false) }
LaunchedEffect(kindResolved) { if (kindResolved) hasSettled = true }
val colorSpec: AnimationSpec<Color> =
if (hasSettled) tween(durationMillis = 350) else snap()
val backgroundColor by animateColorAsState(
targetValue = if (isTyping) Color.Transparent else Color.White,
animationSpec = colorSpec,
Expand All @@ -77,6 +93,23 @@ internal fun RowScope.SendCashButton(
stiffness = Spring.StiffnessMediumLow,
)

// The symbol grows from textMedium to textLarge as the button condenses. Animating the
// fontUnit (rather than swapping styles) lets it ease alongside the width spring so the
// resize and the type scale read as one motion. Sizes come from the theme so they track
// any typography change.
val symbolFontSize by animateFloatAsState(
targetValue = if (isTyping) {
22.sp.value
} else {
CodeTheme.typography.textMedium.fontSize.value
},
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow,
),
label = "send button symbol size",
)

val shape = if (canType) CodeTheme.shapes.medium else CodeTheme.shapes.small

Row(
Expand Down Expand Up @@ -107,6 +140,7 @@ internal fun RowScope.SendCashButton(
val prefix = remember(fullText, state.cashSymbol) {
fullText.removeSuffix(state.cashSymbol)
}

AnimatedVisibility(
visible = !isTyping,
enter = fadeIn() + expandHorizontally(widthSpec, expandFrom = Alignment.Start),
Expand All @@ -123,7 +157,9 @@ internal fun RowScope.SendCashButton(
Text(
text = state.cashSymbol,
color = contentColor,
style = CodeTheme.typography.textMedium,
// 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),
maxLines = 1,
softWrap = false,
)
Expand Down
Loading