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
@@ -1,6 +1,7 @@
package com.flipcash.app.core.chat

import android.os.Parcelable
import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.services.models.chat.ChatId
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
Expand All @@ -18,7 +19,10 @@ sealed interface ChatIdentifier : Parcelable {

@Serializable
@Parcelize
data class ByContact(val e164: String, val displayName: String, val chatId: ChatId? = null) : ChatIdentifier {
override val key: String get() = e164
` data class ByContact(
val contact: DeviceContact,
val chatId: ChatId? = null
) : ChatIdentifier {
override val key: String get() = contact.e164
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.flipcash.app.core.contacts

import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable

@Serializable
@Parcelize
data class DeviceContact(
val e164: String,
val androidContactId: Long = -1L,
val displayName: String,
val photoUri: String?,
val displayNumber: String = "",
): Parcelable {
var isUnknown: Boolean = false
private set

companion object {
fun unknownContact(
e164: String = "",
displayName: String? = null,
displayNumber: String? = null,
) = DeviceContact(
e164 = e164,
androidContactId = -1,
displayName = displayName ?: e164,
displayNumber = displayNumber ?: e164,
photoUri = null,
).apply { isUnknown = true }
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.flipcash.app.directsend.internal

import com.flipcash.app.contacts.device.DeviceContact
import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.services.models.chat.ChatId
import kotlin.time.Instant

Expand Down
Original file line number Diff line number Diff line change
@@ -1,34 +1,29 @@
package com.flipcash.app.directsend.internal

import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.viewModelScope
import com.flipcash.app.contacts.ContactCoordinator
import com.flipcash.app.contacts.ContactCoordinator.ContactState
import com.flipcash.app.contacts.device.DeviceContact
import com.flipcash.app.contacts.device.PickedContactData
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.app.core.send.SendStep
import com.flipcash.app.featureflags.FeatureFlag
import com.flipcash.app.featureflags.FeatureFlagController
import com.flipcash.app.payments.PurchaseMethodController
import com.flipcash.app.phone.PhoneUtils
import com.flipcash.app.permissions.PickedContact
import com.flipcash.app.phone.PhoneUtils
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.features.directsend.R
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.user.UserManager
import com.flipcash.shared.chat.ChatCoordinator
import com.flipcash.shared.chat.ChatSummary
import com.getcode.opencode.model.financial.Token
import com.getcode.solana.keys.Mint
import com.getcode.manager.BottomBarAction
import com.getcode.manager.BottomBarManager
import com.getcode.opencode.model.core.ID
import com.getcode.opencode.model.financial.Token
import com.getcode.solana.keys.Mint
import com.getcode.util.resources.ResourceHelper
import com.getcode.view.BaseViewModel
import com.getcode.view.LoadingSuccessState
Expand Down Expand Up @@ -175,7 +170,10 @@ internal class SendFlowViewModel @Inject constructor(
val (contact, isOnFlipcash) = row
if (isOnFlipcash) {
val identifier = if (contact.e164.isNotEmpty()) {
ChatIdentifier.ByContact(contact.e164, contact.displayName, row.chatId)
ChatIdentifier.ByContact(
contact = contact,
chatId = row.chatId
)
} else {
ChatIdentifier.ByChatId(row.chatId!!)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
Expand All @@ -24,8 +24,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.GroupAdd
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
Expand All @@ -41,14 +40,12 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.foundation.border
import androidx.compose.material.icons.filled.Add
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flipcash.app.contacts.device.DeviceContact
import com.flipcash.app.contacts.ui.ContactAvatar
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.app.core.send.SendResult
import com.flipcash.app.core.send.SendStep
import com.flipcash.app.directsend.internal.ContactListItem
Expand All @@ -65,7 +62,6 @@ import com.getcode.theme.White10
import com.getcode.theme.extraSmall
import com.getcode.ui.components.AppBarDefaults
import com.getcode.ui.components.AppBarWithTitle
import com.getcode.ui.components.CircularIconButton
import com.getcode.ui.components.SearchInput
import com.getcode.ui.core.verticalScrollStateGradient
import com.getcode.ui.theme.CodeScaffold
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ package com.flipcash.app.messenger.internal

import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.flatMap
import androidx.paging.insertSeparators
import com.flipcash.app.contacts.ContactCoordinator
import com.flipcash.app.contacts.device.DeviceContact
import androidx.compose.runtime.snapshotFlow
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.app.core.extensions.onResult
import com.flipcash.app.core.ui.ConfirmationStyle
import com.flipcash.app.featureflags.FeatureFlag
Expand All @@ -26,7 +26,6 @@ import com.flipcash.services.models.buildDmPaymentMetadata
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.DeliveryStatus
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.MessagePointer
import com.flipcash.services.models.chat.TypingState
import com.flipcash.services.user.UserManager
import com.flipcash.shared.amountentry.AmountEntryDelegate
Expand Down Expand Up @@ -210,56 +209,48 @@ internal class ChatViewModel @Inject constructor(
}
}.cachedIn(viewModelScope)

private val maxAmountFlow = combine(
transactionController.limits,
tokenCoordinator.observeSelectedTokenMint()
.flatMapLatest { mint -> tokenCoordinator.balanceForToken(mint) },
exchange.observePreferredRate(),
) { limits, balance, rate ->
val balanceInLocal = balance.convertingTo(rate)
val sendLimit = limits?.sendLimitFor(rate.currency) ?: SendLimit.Zero
Fiat(min(sendLimit.nextTransaction, balanceInLocal.toDouble()), rate.currency)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)

val amountDelegate = AmountEntryDelegate(
exchange = exchange,
scope = viewModelScope,
style = AmountEntryStyle(
actionLabel = resources.getString(R.string.action_swipeToSend),
actionStyle = ConfirmationStyle.Slide,
infoHint = { resources.getString(R.string.subtitle_sendHint, it) },
overMaxHint = { resources.getString(R.string.subtitle_sendHintLimitExceeded, it) },
),
loadingState = stateFlow.map { it.sendProgress }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LoadingSuccessState()),
maxAmount = maxAmountFlow,
)

init {
// Token observation
tokenCoordinator.observeSelectedTokenMint()
.flatMapLatest { mint ->
tokenCoordinator.tokenBalances.map { tokens ->
tokens.find { it.token.address == mint }
}
}
.filterNotNull()
.onEach { tokenWithBalance ->
dispatchEvent(Event.TokenUpdated(tokenWithBalance.token))
}.launchIn(viewModelScope)
private val maxAmountFlow by lazy {
combine(
transactionController.limits,
tokenCoordinator.observeSelectedTokenMint()
.flatMapLatest { mint -> tokenCoordinator.balanceForToken(mint) },
exchange.observePreferredRate(),
) { limits, balance, rate ->
val balanceInLocal = balance.convertingTo(rate)
val sendLimit = limits?.sendLimitFor(rate.currency) ?: SendLimit.Zero
Fiat(min(sendLimit.nextTransaction, balanceInLocal.toDouble()), rate.currency)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
}

exchange.observePreferredRate()
.onEach { rate ->
val currency = exchange.getCurrency(rate.currency.name)
if (currency != null) {
amountDelegate.onCurrencyChanged(currency)
}
}.launchIn(viewModelScope)
val amountDelegate by lazy {
AmountEntryDelegate(
exchange = exchange,
scope = viewModelScope,
style = AmountEntryStyle(
actionLabel = resources.getString(R.string.action_swipeToSend),
actionStyle = ConfirmationStyle.Slide,
infoHint = { resources.getString(R.string.subtitle_sendHint, it) },
overMaxHint = { resources.getString(R.string.subtitle_sendHintLimitExceeded, it) },
),
loadingState = stateFlow.map { it.sendProgress }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LoadingSuccessState()),
maxAmount = maxAmountFlow,
)
}

transactionController.limits
.onEach { dispatchEvent(Event.LimitsChanged(it)) }
.launchIn(viewModelScope)
init {
// Essential — needed immediately for chat display
initChatHandlers()

viewModelScope.launch {
// Yield to let the first frame render before setting up remaining collectors
initTokenAndExchangeObservers()
initTypingHandlers()
initSendHandlers()
}
}

private fun initChatHandlers() {
// Unified chat open handler — resolves chatId and contact from the identifier
eventFlow
.filterIsInstance<Event.OnChatOpened>()
Expand All @@ -269,9 +260,7 @@ internal class ChatViewModel @Inject constructor(
// 1. Resolve chatId
val chatId = when (identifier) {
is ChatIdentifier.ByContact -> identifier.chatId
?: chatCoordinator.getChatId(
DeviceContact.unknownContact(identifier.e164)
).getOrNull()
?: chatCoordinator.getChatId(identifier.contact).getOrNull()
is ChatIdentifier.ByChatId -> identifier.chatId
}

Expand All @@ -285,13 +274,7 @@ internal class ChatViewModel @Inject constructor(
// 2. Resolve contact
when (identifier) {
is ChatIdentifier.ByContact -> {
val contact = contactCoordinator.lookupContact(identifier.e164).getOrElse {
DeviceContact.unknownContact(
e164 = identifier.e164,
displayName = identifier.displayName.takeIf { it.isNotBlank() },
)
}
dispatchEvent(Event.OnContactFound(contact))
dispatchEvent(Event.OnContactFound(identifier.contact))
}
is ChatIdentifier.ByChatId -> {
val contact = contactCoordinator.lookupContactByDmChatId(
Expand Down Expand Up @@ -328,11 +311,39 @@ internal class ChatViewModel @Inject constructor(
chatCoordinator.advanceReadPointer(chatId, event.messageId)
}
.launchIn(viewModelScope)
}

private fun initTokenAndExchangeObservers() {
// Token observation
tokenCoordinator.observeSelectedTokenMint()
.flatMapLatest { mint ->
tokenCoordinator.tokenBalances.map { tokens ->
tokens.find { it.token.address == mint }
}
}
.filterNotNull()
.onEach { tokenWithBalance ->
dispatchEvent(Event.TokenUpdated(tokenWithBalance.token))
}.launchIn(viewModelScope)

exchange.observePreferredRate()
.onEach { rate ->
val currency = exchange.getCurrency(rate.currency.name)
if (currency != null) {
amountDelegate.onCurrencyChanged(currency)
}
}.launchIn(viewModelScope)

transactionController.limits
.onEach { dispatchEvent(Event.LimitsChanged(it)) }
.launchIn(viewModelScope)
}

@OptIn(ExperimentalCoroutinesApi::class)
private fun initTypingHandlers() {
// Dispatch typing notifications based on text changes.
// transformLatest auto-cancels the previous block on each new emission,
// replacing manual Job tracking for idle timeout and heartbeats.
@OptIn(ExperimentalCoroutinesApi::class)
snapshotFlow { stateFlow.value.chatInputState.text.toString() }
.drop(1)
.distinctUntilChanged()
Expand Down Expand Up @@ -410,7 +421,9 @@ internal class ChatViewModel @Inject constructor(
}
.onEach { dispatchEvent(Event.TypingEnabled(it)) }
.launchIn(viewModelScope)
}

private fun initSendHandlers() {
// Send text message
eventFlow.filterIsInstance<Event.SendMessage>()
.map { stateFlow.value.chatInputState }
Expand Down Expand Up @@ -610,7 +623,12 @@ internal class ChatViewModel @Inject constructor(
companion object {
val updateStateForEvent: (Event) -> ((State) -> State) = { event ->
when (event) {
is Event.OnChatOpened -> { state -> state }
is Event.OnChatOpened -> { state ->
when (val id = event.identifier) {
is ChatIdentifier.ByContact -> state.copy(chattingWith = id.contact)
is ChatIdentifier.ByChatId -> state
}
}
is Event.OnContactFound -> { state ->
state.copy(
chattingWith = event.contact
Expand Down
Loading
Loading