diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt index e85b8b6b29..1ba7a8e42b 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt @@ -19,7 +19,11 @@ data class ActivityFeedMessage( val amount: LocalFiat?, val timestamp: Instant, val state: MessageState, - val metadata: MessageMetadata? + val metadata: MessageMetadata?, + // Ordered substitutions for [text]'s indexed placeholders ({0}, {1}, …). Persisted so the + // title can be resolved live at display time (see TransactionItemMapper), keeping it consistent + // with the live-resolved counterparty avatar. + val textSubstitutions: List = emptyList(), ) { val isTransaction: Boolean get() = amount != null @@ -33,6 +37,41 @@ data class ActivityFeedMessage( } } +/** + * A substitution for an indexed placeholder in [ActivityFeedMessage.text]. The core-domain, + * persisted mirror of `com.flipcash.services.models.Substitution` (mapped at the persistence + * boundary, like [MessageMetadata]). [fallback] is the server-provided name; the client prefers + * a live-resolved name for [UserId] where available. + */ +@Serializable +sealed interface MessageSubstitution { + val fallback: String + + @Serializable + data class Phone( + override val fallback: String, + val phoneNumber: String, + ) : MessageSubstitution + + @Serializable + data class UserId( + override val fallback: String, + val userId: ID, + ) : MessageSubstitution + + companion object { + /** Deserializes the persisted JSON list; empty (never throws) on null/garbage. */ + fun listFrom(json: String?): List { + json ?: return emptyList() + return try { + Json.decodeFromString>(json) + } catch (e: Exception) { + emptyList() + } + } + } +} + enum class MessageState { UNKNOWN, PENDING, diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenCardStack.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenCardStack.kt index a7aed893f5..3667e156b6 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenCardStack.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenCardStack.kt @@ -11,16 +11,15 @@ import kotlin.collections.forEach /** * A vertical stack of [TokenCard]s that fans out (each card revealing its [fannedReveal] header) and - * **sticks per-card** on scroll: as a card scrolls above the viewport top it pins into a growing - * deck at the top ([collapsedReveal] per pinned card) while the cards below stay fanned and readable. + * **collapses, then scrolls off**: as the stack scrolls up, cards pin at the top and tighten from the + * fanned gap to [collapsedReveal] (a growing deck); once fully collapsed the deck **releases** and + * scrolls off the top with the rest of the list (it does not pin permanently). * - * The stack's measured height is always the *fanned* height, so the enclosing list scrolls stably - * (cards are only repositioned, never resized — no feedback into the scroll range). Each card sits - * at `max(fannedY, pinnedY)`, a continuous transition from fanned to pinned with no jump. - * - * [scrolledPast] = how many px the stack's top has scrolled above the viewport top (`-itemOffset`); - * a lambda so the layout re-reads it on scroll without recomposing the whole stack. Cards are drawn - * front-to-back so the last (highest-value) card sits on top. + * The measured height is always the *fanned* height, so the enclosing list's scroll range is stable + * (cards are only repositioned, never resized). [scrolledPast] = px the stack's top has scrolled above + * the viewport top (`-itemOffset`); it is read in the **placement** phase so scrolling only re-places + * the cards and never re-measures/re-composes them. [pinInset] holds the collapsing deck below top + * chrome (e.g. the status bar). Cards are drawn front-to-back so the last (highest-value) sits on top. */ @Composable fun TokenCardStack( @@ -47,15 +46,22 @@ fun TokenCardStack( ) { measurables, constraints -> val fannedPx = fannedReveal.roundToPx() val collapsedPx = collapsedReveal.roundToPx() - // [pinInset] holds the pinned deck below any top chrome (e.g. the status bar) once cards stick. val pinInsetPx = pinInset.roundToPx() - // Not clamped to ≥0: a negative value (stack below the pin line) keeps cards fanned flush. - val past = scrolledPast() val placeables = measurables.map { it.measure(constraints.copy(minHeight = 0)) } val cardPx = placeables.firstOrNull()?.height ?: 0 - // Always the fanned height, so the list scroll range is stable while cards pin. + // Always the fanned height, so the list's scroll range is stable while cards collapse. val height = if (placeables.isEmpty()) 0 else cardPx + fannedPx * (placeables.size - 1) + // Scroll distance at which every card has finished collapsing (the last card pins last). + val collapseComplete = + ((placeables.size - 1) * (fannedPx - collapsedPx) - pinInsetPx).coerceAtLeast(0) layout(constraints.maxWidth, height) { + // Read scroll offset HERE (placement) — not in the measure scope — so scrolling only + // re-places the cards; reading it while measuring would re-run each card's SubcomposeLayout. + // Cap only the UPPER bound at collapseComplete: once fully collapsed the deck stops pinning + // and the frozen layout scrolls off with the list. The value is intentionally allowed to go + // negative — at rest the stack sits below the top chrome, and that negative keeps `pinnedY` + // under `fannedY` so every card (including the last) stays fanned instead of collapsing. + val past = scrolledPast().coerceAtMost(collapseComplete.toFloat()) placeables.forEachIndexed { index, placeable -> val fannedY = index * fannedPx val pinnedY = (past + pinInsetPx + index * collapsedPx).toInt() diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index cfa76f4628..86417e48b7 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -423,6 +423,7 @@ Advanced Wallet + Recent Wallet Send as a Link diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/BalanceScreen.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/BalanceScreen.kt index 1dbcc837a2..877df4c445 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/BalanceScreen.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/BalanceScreen.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.flipcash.app.balance.internal.BalanceScreen -import com.flipcash.app.balance.internal.BalanceViewModel +import com.flipcash.app.balance.internal.WalletViewModel import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.tokens.ui.SelectTokenViewModel @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +@Deprecated("Replaced by WalletScreen in new UI") @Composable fun BalanceScreen() { val navigator = LocalCodeNavigator.current @@ -37,7 +38,7 @@ fun BalanceScreen() { } ) - val viewModel = hiltViewModel() + val viewModel = hiltViewModel() val tokenViewModel = hiltViewModel() BalanceScreen(viewModel, tokenViewModel) @@ -51,7 +52,7 @@ fun BalanceScreen() { LaunchedEffect(viewModel) { viewModel.eventFlow - .filterIsInstance() + .filterIsInstance() .onEach { navigator.push(AppRoute.Main.RegionSelection) }.launchIn(this) @@ -59,7 +60,7 @@ fun BalanceScreen() { LaunchedEffect(viewModel) { viewModel.eventFlow - .filterIsInstance() + .filterIsInstance() .map { it.screen } .onEach { navigator.push(it) } .launchIn(this) diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/WalletScreen.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/WalletScreen.kt index 0c401bd0b4..aad4f47e9d 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/WalletScreen.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/WalletScreen.kt @@ -7,7 +7,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.flipcash.app.balance.internal.BalanceViewModel +import com.flipcash.app.balance.internal.WalletViewModel import com.flipcash.app.balance.internal.WalletScreen import com.flipcash.app.core.AppRoute import com.flipcash.app.core.extensions.openAsSheet @@ -26,7 +26,7 @@ fun WalletScreen() { modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { - val viewModel = hiltViewModel() + val viewModel = hiltViewModel() val tokenViewModel = hiltViewModel() WalletScreen(viewModel, tokenViewModel) @@ -40,7 +40,7 @@ fun WalletScreen() { LaunchedEffect(viewModel) { viewModel.eventFlow - .filterIsInstance() + .filterIsInstance() .onEach { navigator.openAsSheet(AppRoute.Main.RegionSelection) }.launchIn(this) @@ -48,7 +48,7 @@ fun WalletScreen() { LaunchedEffect(viewModel) { viewModel.eventFlow - .filterIsInstance() + .filterIsInstance() .map { it.screen } .onEach { navigator.openAsSheet(it) } .launchIn(this) diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceScreenContent.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceScreenContent.kt index 9cc3639a1e..4c118de098 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceScreenContent.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceScreenContent.kt @@ -26,7 +26,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.balance.internal.components.BalanceHeader import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.TokenPurpose -import com.flipcash.app.theme.FlipcashPreview import com.flipcash.app.tokens.ui.SelectTokenViewModel import com.flipcash.app.core.ui.rememberTokenBalanceRowStyling import com.flipcash.app.theme.FlipcashThemeWrapper @@ -42,7 +41,7 @@ import com.getcode.ui.theme.CodeButton @Composable internal fun BalanceScreen( - viewModel: BalanceViewModel, + viewModel: WalletViewModel, tokenViewModel: SelectTokenViewModel, ) { val balanceState by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -56,7 +55,7 @@ internal fun BalanceScreen( @Composable internal fun BalanceScreenContent( tokenState: SelectTokenViewModel.State, - dispatchEvent: (BalanceViewModel.Event) -> Unit + dispatchEvent: (WalletViewModel.Event) -> Unit ) { Column { val tokens = remember(tokenState.tokens) { tokenState.tokens } @@ -69,7 +68,7 @@ internal fun BalanceScreenContent( balance = tokenState.totalBalance, appreciation = tokenState.aggregateAppreciation, ) { - dispatchEvent(BalanceViewModel.Event.OpenCurrencySelection) + dispatchEvent(WalletViewModel.Event.OpenCurrencySelection) } Spacer(modifier = Modifier.padding(CodeTheme.dimens.grid.x2)) @@ -115,7 +114,7 @@ internal fun BalanceScreenContent( CodeButton( onClick = { - dispatchEvent(BalanceViewModel.Event.PresentDepositOptions) + dispatchEvent(WalletViewModel.Event.PresentDepositOptions) }, modifier = Modifier .padding(top = CodeTheme.dimens.grid.x2) @@ -140,7 +139,7 @@ internal fun BalanceScreenContent( buttonState = ButtonState.Filled10, onClick = { dispatchEvent( - BalanceViewModel.Event.PresentDepositOptions + WalletViewModel.Event.PresentDepositOptions ) } ) @@ -149,7 +148,7 @@ internal fun BalanceScreenContent( tokens = tokens, onTokenSelected = { dispatchEvent( - BalanceViewModel.Event.OpenScreen( + WalletViewModel.Event.OpenScreen( AppRoute.Token.Info(it.address) ) ) diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt index 652b182ad2..504a59d3a2 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt @@ -3,7 +3,6 @@ package com.flipcash.app.balance.internal 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 @@ -15,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.Icon import androidx.compose.material.icons.Icons @@ -22,19 +22,10 @@ import androidx.compose.material.icons.outlined.AddCircleOutline import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute import com.flipcash.app.core.ui.TokenCardStack @@ -44,13 +35,14 @@ import com.flipcash.app.balance.internal.components.OnboardingItem import com.flipcash.app.core.navigation.LocalTabBarPadding import com.flipcash.app.tokens.ui.SelectTokenViewModel import com.flipcash.features.balance.R +import com.flipcash.shared.transactionhistory.ActivityFeedRow import com.getcode.theme.CodeTheme private const val TokenStackKey = "tokenStack" @Composable internal fun WalletScreen( - viewModel: BalanceViewModel, + viewModel: WalletViewModel, tokenViewModel: SelectTokenViewModel, ) { val balanceState by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -64,17 +56,14 @@ internal fun WalletScreen( @Composable internal fun WalletScreenContent( - balanceState: BalanceViewModel.State, + balanceState: WalletViewModel.State, tokenState: SelectTokenViewModel.State, - dispatchEvent: (BalanceViewModel.Event) -> Unit + dispatchEvent: (WalletViewModel.Event) -> Unit, ) { val listState = rememberLazyListState() - // Sticky per-card collapse: the fan scrolls normally (the stack keeps a fixed fanned height, so - // scrolling is stable) and each card pins to the top as it scrolls above the viewport, building a - // deck while the cards below stay fanned and readable. `scrolledPast` = px of the stack scrolled - // above the viewport top, read live so the stack re-lays-out its cards as the list scrolls. - // May be negative when the stack sits below the pin line (i.e. scrolled to the top) so cards - // fan flush there instead of staying stuck under the pin inset. + // Px the token stack has scrolled above the viewport top, read live so the stack collapses (then + // releases and scrolls off) as the list scrolls. A lambda so the stack reads it in its placement + // phase without recomposing. val scrolledPast = { listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == TokenStackKey } ?.let { -it.offset.toFloat() } ?: 0f @@ -100,7 +89,7 @@ internal fun WalletScreenContent( balance = tokenState.totalBalance, appreciation = tokenState.aggregateAppreciation, ) { - dispatchEvent(BalanceViewModel.Event.OpenCurrencySelection) + dispatchEvent(WalletViewModel.Event.OpenCurrencySelection) } } @@ -118,7 +107,7 @@ internal fun WalletScreenContent( ) { item -> when (item) { is OnboardingItem.AddMoney -> { - dispatchEvent(BalanceViewModel.Event.PresentDepositOptions) + dispatchEvent(WalletViewModel.Event.PresentDepositOptions) } is OnboardingItem.ScanTipCard -> { @@ -137,7 +126,7 @@ internal fun WalletScreenContent( scrolledPast = scrolledPast, onCardClick = { token -> dispatchEvent( - BalanceViewModel.Event.OpenScreen( + WalletViewModel.Event.OpenScreen( AppRoute.Token.Info(mint = token.token.address) ) ) @@ -151,7 +140,7 @@ internal fun WalletScreenContent( Box( modifier = Modifier .fillMaxWidth() - .clickable(onClick = { dispatchEvent(BalanceViewModel.Event.PresentDepositOptions) }) + .clickable(onClick = { dispatchEvent(WalletViewModel.Event.PresentDepositOptions) }) .padding(vertical = CodeTheme.dimens.inset), contentAlignment = Alignment.Center, ) { @@ -173,5 +162,27 @@ internal fun WalletScreenContent( } } } + + if (balanceState.transactions.isNotEmpty()) { + item(key = "recentHeader") { + Text( + text = stringResource(R.string.title_recentActivity), + style = CodeTheme.typography.screenTitle, + color = CodeTheme.colors.textMain, + modifier = Modifier.padding( + top = CodeTheme.dimens.grid.x4, + bottom = CodeTheme.dimens.grid.x1, + ), + ) + } + // Preview of the most recent activity (newest first); the full history lives on its own + // screen. The VM/coordinator already bounds this list, so just render it. + items( + items = balanceState.transactions, + key = { it.id }, + ) { item -> + ActivityFeedRow(item = item, modifier = Modifier.fillMaxWidth()) + } + } } } \ No newline at end of file diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt similarity index 79% rename from apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt rename to apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt index bbb77fd3b4..ce4bf80370 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt @@ -6,6 +6,7 @@ import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.balance.internal.components.OnboardingItem import com.flipcash.app.core.AppRoute import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator +import com.flipcash.shared.transactionhistory.TransactionListItem import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.shared.chat.ChatCoordinator @@ -13,7 +14,6 @@ import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.flipcash.services.user.AuthState import com.flipcash.services.user.UserManager import com.flipcash.libs.coroutines.DispatcherProvider -import com.getcode.opencode.utils.combine import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.combine @@ -27,7 +27,7 @@ import kotlinx.coroutines.flow.onEach import javax.inject.Inject @HiltViewModel -internal class BalanceViewModel @Inject constructor( +internal class WalletViewModel @Inject constructor( userManager: UserManager, userFlags: UserFlagsCoordinator, dispatchers: DispatcherProvider, @@ -35,7 +35,7 @@ internal class BalanceViewModel @Inject constructor( analytics: FlipcashAnalyticsService, chatCoordinator: ChatCoordinator, feedCoordinator: ActivityFeedCoordinator, -) : BaseViewModel( +) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, defaultDispatcher = dispatchers.Default, @@ -43,6 +43,12 @@ internal class BalanceViewModel @Inject constructor( data class State( val preferredOnRampProvider: OnRampProvider.Defined? = null, val onboardingItems: List = emptyList(), + /** + * Preview of the most recent unified cross-token activity — at most [RECENT_PREVIEW_COUNT] + * rows. The coordinator owns the mapping and enforces the limit; the full paged history is a + * separate dive-in screen. + */ + val transactions: List = emptyList(), ) { val hasAddedMoney: Boolean get() = onboardingItems.find { it is OnboardingItem.AddMoney }?.isCompleted == true @@ -53,6 +59,7 @@ internal class BalanceViewModel @Inject constructor( sealed interface Event { data class OnOnboardingItemsUpdated(val items: List): Event + data class OnTransactionsUpdated(val transactions: List) : Event data class OnPreferredOnRampProviderChanged(val provider: OnRampProvider.Defined?) : Event data object OpenCurrencySelection : Event @@ -62,6 +69,11 @@ internal class BalanceViewModel @Inject constructor( } init { + // Preview of recent activity (bounded to RECENT_PREVIEW_COUNT by the coordinator). + feedCoordinator.recentTransactions(limit = RECENT_PREVIEW_COUNT) + .onEach { dispatchEvent(Event.OnTransactionsUpdated(it)) } + .launchIn(viewModelScope) + userManager.state .filter { it.authState is AuthState.Ready } .flatMapLatest { userFlags.resolvedFlags } @@ -94,6 +106,9 @@ internal class BalanceViewModel @Inject constructor( } internal companion object { + /** Rows of recent activity previewed on the wallet screen (the rest lives on the dive-in). */ + const val RECENT_PREVIEW_COUNT = 3 + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { Event.OpenCurrencySelection -> { state -> state } @@ -103,6 +118,9 @@ internal class BalanceViewModel @Inject constructor( is Event.OnOnboardingItemsUpdated -> { state -> state.copy(onboardingItems = event.items) } + is Event.OnTransactionsUpdated -> { state -> + state.copy(transactions = event.transactions) + } Event.PresentDepositOptions -> { state -> state } is Event.OpenScreen -> { state -> state } } diff --git a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceScreenContentTest.kt b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceScreenContentTest.kt index d3c495359d..4034050bc3 100644 --- a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceScreenContentTest.kt +++ b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceScreenContentTest.kt @@ -31,7 +31,7 @@ class BalanceScreenContentTest { @get:Rule val composeTestRule = createComposeRule() - private var lastEvent: BalanceViewModel.Event? = null + private var lastEvent: WalletViewModel.Event? = null private fun setEmptyBalanceScreen() { lastEvent = null @@ -71,6 +71,6 @@ class BalanceScreenContentTest { fun `tapping add money opens deposit options`() { setEmptyBalanceScreen() composeTestRule.onNodeWithText("Add Money").performClick() - assertTrue(lastEvent is BalanceViewModel.Event.PresentDepositOptions) + assertTrue(lastEvent is WalletViewModel.Event.PresentDepositOptions) } } diff --git a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelStateTest.kt b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelStateTest.kt index a62f9ad003..e3425a0715 100644 --- a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelStateTest.kt +++ b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelStateTest.kt @@ -7,39 +7,39 @@ import kotlin.test.assertNull class BalanceViewModelStateTest { - private val reduce = BalanceViewModel.Companion.updateStateForEvent + private val reduce = WalletViewModel.Companion.updateStateForEvent @Test fun `default state has null provider`() { - assertNull(BalanceViewModel.State().preferredOnRampProvider) + assertNull(WalletViewModel.State().preferredOnRampProvider) } @Test fun `OnPreferredOnRampProviderChanged updates provider`() { val provider = OnRampProvider.ManualDeposit val updated = reduce( - BalanceViewModel.Event.OnPreferredOnRampProviderChanged(provider) - )(BalanceViewModel.State()) + WalletViewModel.Event.OnPreferredOnRampProviderChanged(provider) + )(WalletViewModel.State()) assertEquals(provider, updated.preferredOnRampProvider) } @Test fun `OnPreferredOnRampProviderChanged with null clears provider`() { - val state = BalanceViewModel.State( + val state = WalletViewModel.State( preferredOnRampProvider = OnRampProvider.ManualDeposit ) val updated = reduce( - BalanceViewModel.Event.OnPreferredOnRampProviderChanged(null) + WalletViewModel.Event.OnPreferredOnRampProviderChanged(null) )(state) assertNull(updated.preferredOnRampProvider) } @Test fun `OpenCurrencySelection is no-op`() { - val state = BalanceViewModel.State( + val state = WalletViewModel.State( preferredOnRampProvider = OnRampProvider.ManualDeposit ) - val updated = reduce(BalanceViewModel.Event.OpenCurrencySelection)(state) + val updated = reduce(WalletViewModel.Event.OpenCurrencySelection)(state) assertEquals(state, updated) } } diff --git a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelTest.kt b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelTest.kt index 0fd6889461..3855781037 100644 --- a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelTest.kt +++ b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelTest.kt @@ -45,7 +45,7 @@ class BalanceViewModelTest { private lateinit var dispatchers: TestDispatchers - private fun createViewModel() = BalanceViewModel( + private fun createViewModel() = WalletViewModel( userManager = userManager, userFlags = userFlags, dispatchers = dispatchers, @@ -63,7 +63,7 @@ class BalanceViewModelTest { coEvery { purchaseMethodController.presentDepositOptions(popToRoot = true) } returns route val vm = createViewModel() - vm.dispatchEvent(BalanceViewModel.Event.PresentDepositOptions) + vm.dispatchEvent(WalletViewModel.Event.PresentDepositOptions) advanceUntilIdle() // The removed AddMoneyUX flag used to short-circuit to a plain Deposit route; @@ -74,9 +74,9 @@ class BalanceViewModelTest { @Test fun `OnPreferredOnRampProviderChanged updates state`() { val provider = mockk() - val updated = BalanceViewModel.updateStateForEvent( - BalanceViewModel.Event.OnPreferredOnRampProviderChanged(provider) - )(BalanceViewModel.State()) + val updated = WalletViewModel.updateStateForEvent( + WalletViewModel.Event.OnPreferredOnRampProviderChanged(provider) + )(WalletViewModel.State()) assertEquals(provider, updated.preferredOnRampProvider) } } diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/DecorView.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/DecorView.kt index 942e435b81..6ee5921e4e 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/DecorView.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/DecorView.kt @@ -42,6 +42,8 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.bill.customization.LocalBillPlaygroundController import com.flipcash.app.core.bill.BillState +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.scanner.internal.ScannerDecorItem import com.flipcash.app.session.SessionState import com.flipcash.features.scanner.R @@ -62,9 +64,12 @@ internal fun DecorView( zoomRatio: Float = 1f, onAction: (ScannerDecorItem) -> Unit, ) { + val features = LocalFeatureFlags.current val billPlayground = LocalBillPlaygroundController.current val playgroundState by billPlayground.state.collectAsStateWithLifecycle() + val isNewUi by features.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() + val isUsingPlayground by remember( playgroundState.isCustomizing, playgroundState.context, @@ -74,7 +79,7 @@ internal fun DecorView( } } - AnimatedVisibility(!isUsingPlayground) { + AnimatedVisibility(!isUsingPlayground && !isNewUi) { Box( modifier = Modifier .fillMaxSize() diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/27.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/27.json new file mode 100644 index 0000000000..89d189b7a5 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/27.json @@ -0,0 +1,751 @@ +{ + "formatVersion": 1, + "database": { + "version": 27, + "identityHash": "4b64b2a75366d6bca2e56f1e25fdef85", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + } + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4b64b2a75366d6bca2e56f1e25fdef85')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index 297107dc85..3f4d72cbe1 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -11,6 +11,9 @@ import androidx.room.TypeConverters import androidx.room.migration.AutoMigrationSpec import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import com.flipcash.app.persistence.converters.ChatTypeConverters import com.flipcash.app.persistence.converters.TokenTypeConverters import com.flipcash.app.persistence.dao.BlockedUserDao @@ -82,8 +85,9 @@ import com.getcode.utils.subByteArray // 25 -> 26 is a manual migration (MIGRATION_25_26): it normalizes the // per-row user_profile_json blob into the shared user_profiles table, which // needs data movement an AutoMigration can't express. + AutoMigration(from = 26, to = 27), // messages.text_substitutions (nullable) ], - version = 26, + version = 27, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { @@ -263,7 +267,17 @@ abstract class FlipcashDatabase : RoomDatabase() { } } + // Reactive mirror of [instance] so consumers can observe the DB becoming available/torn down + // (the per-user DB is created on login, after singletons build their flow graphs) instead of + // polling. Kept in lockstep with [instance] in init()/closeDb(). + private val instanceState = MutableStateFlow(null) + fun observeInstance(): StateFlow = instanceState.asStateFlow() + private var instance: FlipcashDatabase? = null + set(value) { + field = value + instanceState.value = value + } fun requireInstance() = requireNotNull(instance) fun getInstance(): FlipcashDatabase? = instance private var dbName: String = "" diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/MessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/MessageDao.kt index 65eeaf5476..1b75e8a629 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/MessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/MessageDao.kt @@ -33,6 +33,10 @@ interface MessageDao { @Query("SELECT * FROM messages ORDER BY timestamp DESC") fun observeMessages(): PagingSource + /** The [limit] most recent messages, newest first — the wallet's recent-activity preview. */ + @Query("SELECT * FROM messages ORDER BY timestamp DESC LIMIT :limit") + fun observeRecent(limit: Int): Flow> + @Query("SELECT * FROM messages") suspend fun getAllMessages(): List diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt index 2b4946a4ed..471a0f8cfa 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt @@ -7,6 +7,7 @@ import androidx.room.Query import androidx.room.Update import com.flipcash.app.persistence.entities.UserProfileEntity import com.flipcash.services.models.chat.MediaItem +import kotlinx.coroutines.flow.Flow @Dao interface UserProfileDao { @@ -54,6 +55,10 @@ interface UserProfileDao { @Query("SELECT * FROM user_profiles WHERE user_id_hex = :userIdHex LIMIT 1") suspend fun getByUserId(userIdHex: String): UserProfileEntity? + /** Observes every cached profile; re-emits as profiles are added/updated. */ + @Query("SELECT * FROM user_profiles") + fun observeAll(): Flow> + /** A batch of rows still carrying a staged legacy blob; drives [backfillMigratedProfiles]. */ @Query("SELECT * FROM user_profiles WHERE pending_migration_json IS NOT NULL LIMIT :limit") suspend fun pendingMigrationBatch(limit: Int): List diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/MessageEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/MessageEntity.kt index 7cf60dc012..214a1da4fa 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/MessageEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/MessageEntity.kt @@ -22,6 +22,8 @@ data class MessageEntity( val metadata: String?, @ColumnInfo(defaultValue = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") val mintBase58: String?, + // JSON-serialized List for text's placeholders; null when none. + val textSubstitutions: String? = null, ) { val id: List get() = Base58.decode(idBase58).toList() diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt index 93bf33f6b8..ce45dd3deb 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt @@ -11,8 +11,11 @@ import com.flipcash.app.persistence.sources.mapper.notifications.NotificationToE import com.flipcash.services.models.ActivityFeedNotification import com.flipcash.services.persistence.PagingDataSource import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton @@ -59,6 +62,19 @@ class MessageDataSource @Inject constructor( fun hasEverAddedMoney(): Flow = db?.messageDao()?.hasEverAddedMoney() ?: flowOf(false) + /** + * Observes the [limit] most recent messages (newest first) as domain models. Reacts to the + * per-user DB becoming available (created on login) via [FlipcashDatabase.observeInstance] rather + * than capturing a possibly-null instance once, so it emits as soon as the DB is ready. + */ + @OptIn(ExperimentalCoroutinesApi::class) + fun observeRecent(limit: Int): Flow> = + FlipcashDatabase.observeInstance().flatMapLatest { database -> + database?.messageDao()?.observeRecent(limit)?.map { entities -> + entities.map { messageEntityMapper.map(it) } + } ?: flowOf(emptyList()) + } + override fun observe(): PagingSource { return db?.messageDao()?.observeMessages() ?: object : PagingSource() { override fun getRefreshKey(state: PagingState): Int? = null diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/UserProfileDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/UserProfileDataSource.kt index 611b10b2b3..3ef41a9db4 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/UserProfileDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/UserProfileDataSource.kt @@ -2,8 +2,17 @@ package com.flipcash.app.persistence.sources import com.flipcash.app.persistence.FlipcashDatabase import com.flipcash.app.persistence.entities.toSerialized +import com.flipcash.app.persistence.sources.mapper.toDomain +import com.flipcash.services.models.UserProfile import com.getcode.opencode.model.core.ID import com.getcode.utils.hexEncodedString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton @@ -24,4 +33,45 @@ class UserProfileDataSource @Inject constructor() { // toSerialized() also resolves rows still carrying a not-yet-backfilled migration blob. return entity.toSerialized().displayName?.takeIf { it.isNotBlank() } } + + /** The cached [UserProfile] for [userId], or null if the user isn't cached. */ + suspend fun getCachedProfile(userId: ID): UserProfile? { + val entity = db?.userProfileDao()?.getByUserId(userId.hexEncodedString()) ?: return null + return entity.toSerialized().toDomain() + } + + /** + * Stores [profile]'s name + avatar for [userId] (INSERT OR REPLACE, preserving any existing + * phone/email/social columns). Used to back-fill the cache after a network resolve so + * [observeProfiles] re-emits and consumers (e.g. the transaction list) resolve the row live. + */ + suspend fun store(userId: ID, profile: UserProfile) { + db?.userProfileDao()?.upsertNameAndAvatar( + userIdHex = userId.hexEncodedString(), + displayName = profile.displayName, + profilePicture = profile.profilePicture, + ) + } + + /** + * Observes the cached profiles as a `user-id-hex → `[UserProfile] map. Re-emits as profiles are + * added/updated, so consumers (e.g. the transaction list) resolve counterparties reactively from + * memory — no per-item I/O. + * + * The DB is resolved at **collection** time (not when this is called) and we wait for it to + * become available: the per-user DB is created on login, but consumers like the wallet + * coordinator are `@Singleton`s that may build their flow graph *before* login. Capturing a null + * `db` once (the previous `?: flowOf(emptyMap())`) permanently pinned the stream to empty — so + * avatars/names never resolved even after the DB was ready. Deferring + polling fixes that. + */ + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + fun observeProfiles(): Flow> = + FlipcashDatabase.observeInstance() + .flatMapLatest { database -> + database?.userProfileDao()?.observeAll()?.map { rows -> + rows.associate { it.userIdHex to it.toSerialized().toDomain() } + } ?: flowOf(emptyMap()) + } + .flowOn(Dispatchers.Default) + .distinctUntilChanged() } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/UserProfileDomainMappers.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/UserProfileDomainMappers.kt new file mode 100644 index 0000000000..b0f585939e --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/UserProfileDomainMappers.kt @@ -0,0 +1,33 @@ +package com.flipcash.app.persistence.sources.mapper + +import com.flipcash.app.persistence.converters.SocialAccountSerialized +import com.flipcash.app.persistence.converters.UserProfileSerialized +import com.flipcash.services.models.SocialAccount +import com.flipcash.services.models.UserProfile + +/** + * Cached serialized profile → domain [UserProfile]. Shared by the chat-member read + * ([com.flipcash.app.persistence.sources.mapper.chat.ChatEntityMapper]) and the + * user_profiles cache read ([com.flipcash.app.persistence.sources.UserProfileDataSource]). + */ +fun UserProfileSerialized.toDomain(): UserProfile = UserProfile( + displayName = displayName.orEmpty(), + socialAccounts = socialAccounts.map { it.toDomain() }, + phoneNumber = phoneNumber, + email = email, + profilePicture = profilePicture, +) + +fun SocialAccountSerialized.toDomain(): SocialAccount = when (this) { + is SocialAccountSerialized.TwitterX -> SocialAccount.TwitterX( + id = id, + username = username, + name = name, + description = description, + profilePicUrl = profilePicUrl, + verifiedType = verifiedType?.let { name -> + SocialAccount.TwitterX.VerifiedType.entries.firstOrNull { it.name == name } + }, + followerCount = followerCount, + ) +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index 1188a8bcf4..42a1532276 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -14,6 +14,7 @@ import com.flipcash.app.persistence.entities.ChatMetadataEntity import com.flipcash.app.persistence.entities.MessageStatus import com.flipcash.app.persistence.entities.UserProfileEntity import com.flipcash.app.persistence.entities.toSerialized +import com.flipcash.app.persistence.sources.mapper.toDomain import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId @@ -291,14 +292,6 @@ private fun MessagePointerSerialized.toDomain(): MessagePointer = MessagePointer timestamp = Instant.fromEpochSeconds(timestampEpochSeconds), ) -private fun UserProfileSerialized.toDomain(): UserProfile = UserProfile( - displayName = displayName.orEmpty(), - socialAccounts = socialAccounts.map { it.toDomain() }, - phoneNumber = phoneNumber, - email = email, - profilePicture = profilePicture, -) - private fun SocialAccount.toSerialized(): SocialAccountSerialized = when (this) { is SocialAccount.TwitterX -> SocialAccountSerialized.TwitterX( id = id, @@ -311,20 +304,6 @@ private fun SocialAccount.toSerialized(): SocialAccountSerialized = when (this) ) } -private fun SocialAccountSerialized.toDomain(): SocialAccount = when (this) { - is SocialAccountSerialized.TwitterX -> SocialAccount.TwitterX( - id = id, - username = username, - name = name, - description = description, - profilePicUrl = profilePicUrl, - verifiedType = verifiedType?.let { name -> - SocialAccount.TwitterX.VerifiedType.entries.firstOrNull { it.name == name } - }, - followerCount = followerCount, - ) -} - private fun String.hexToIdExt(): List { val len = length val data = ByteArray(len / 2) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/MessageEntityToFeedMessageMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/MessageEntityToFeedMessageMapper.kt index daf8dfc808..c627237505 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/MessageEntityToFeedMessageMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/MessageEntityToFeedMessageMapper.kt @@ -4,6 +4,7 @@ import com.flipcash.app.persistence.entities.MessageEntity import com.flipcash.app.core.feed.ActivityFeedMessage import com.flipcash.app.core.feed.MessageMetadata import com.flipcash.app.core.feed.MessageState +import com.flipcash.app.core.feed.MessageSubstitution import com.getcode.opencode.mapper.Mapper import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat @@ -42,7 +43,8 @@ class MessageEntityToFeedMessageMapper @Inject constructor() : Mapper MessageSubstitution.Phone(fallback, phoneNumber) + is Substitution.UserId -> MessageSubstitution.UserId(fallback, userId) + } } class MetadataMapper @Inject constructor(): Mapper { diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mediator/FeedRemoteMediator.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mediator/FeedRemoteMediator.kt index 9fc6091687..7d9d95924d 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mediator/FeedRemoteMediator.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mediator/FeedRemoteMediator.kt @@ -17,6 +17,9 @@ import kotlinx.coroutines.withContext class FeedRemoteMediator( private val controller: ActivityFeedController, private val dataSource: MessageDataSource, + // Invoked with each freshly-fetched page after it's persisted, so callers can back-fill related + // data (e.g. resolve counterparty profiles) for messages as they arrive from the network. + private val onFetched: suspend (List) -> Unit = {}, ): RemoteMediator() { override suspend fun initialize(): InitializeAction { @@ -58,6 +61,8 @@ class FeedRemoteMediator( dataSource.upsert(notifications) } + onFetched(notifications) + MediatorResult.Success(endOfPaginationReached = notifications.isEmpty()) } catch (e: Exception) { MediatorResult.Error(e) diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapperTest.kt new file mode 100644 index 0000000000..b80e8717b1 --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapperTest.kt @@ -0,0 +1,70 @@ +package com.flipcash.app.persistence.sources.mapper.notifications + +import com.flipcash.app.core.feed.MessageSubstitution +import com.flipcash.services.models.ActivityFeedNotification +import com.flipcash.services.models.NotificationMetadata +import com.flipcash.services.models.NotificationState +import com.flipcash.services.models.Substitution +import com.getcode.opencode.model.core.ID +import kotlin.time.Instant +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class NotificationToEntityMapperTest { + + private val mapper = SingleNotificationToEntityMapper(MetadataMapper()) + + private fun notification( + text: String, + substitutions: List = emptyList(), + ) = ActivityFeedNotification( + id = listOf(0x01, 0x02, 0x03), + text = text, + amount = null, + timestamp = Instant.fromEpochSeconds(1700000000L), + state = NotificationState.COMPLETED, + metadata = NotificationMetadata.Unknown, + textSubstitutions = substitutions, + ) + + @Test + fun `persists the raw title template and the serialized substitutions`() { + val userId: ID = listOf(0x0A, 0x0B) + val entity = mapper.map( + notification( + text = "Tipped {0}", + substitutions = listOf(Substitution.UserId(fallback = "Sally", userId = userId)), + ) + ) + + // The template is stored raw — resolution happens live at display. + assertEquals("Tipped {0}", entity.text) + // Substitutions round-trip through the persisted JSON into core MessageSubstitutions. + assertEquals( + listOf(MessageSubstitution.UserId(fallback = "Sally", userId = userId)), + MessageSubstitution.listFrom(entity.textSubstitutions), + ) + } + + @Test + fun `maps a phone substitution`() { + val entity = mapper.map( + notification( + text = "{0} paid you", + substitutions = listOf(Substitution.Phone(fallback = "Bob", phoneNumber = "+15551234567")), + ) + ) + assertEquals( + listOf(MessageSubstitution.Phone(fallback = "Bob", phoneNumber = "+15551234567")), + MessageSubstitution.listFrom(entity.textSubstitutions), + ) + } + + @Test + fun `no substitutions leaves the column null`() { + val entity = mapper.map(notification(text = "Deposited \$30.00")) + assertEquals("Deposited \$30.00", entity.text) + assertNull(entity.textSubstitutions) + } +} diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt index b833f9a526..3a581257d9 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt @@ -119,6 +119,10 @@ class TokenCoordinator @Inject constructor( val tokens: Flow> = _state.map { it.tokens.values.toList() } + /** Cache-only, network-free view of the in-memory token map (see [TokenMetadataProvider]). */ + override fun observeTokenCache(): Flow> = + _state.map { it.tokens }.distinctUntilChanged() + val tokenBalances: Flow> = _hydrated .filter { it } .flatMapLatest { diff --git a/apps/flipcash/shared/transaction-history/build.gradle.kts b/apps/flipcash/shared/transaction-history/build.gradle.kts index a390367792..5380887db2 100644 --- a/apps/flipcash/shared/transaction-history/build.gradle.kts +++ b/apps/flipcash/shared/transaction-history/build.gradle.kts @@ -12,9 +12,13 @@ android { dependencies { implementation(libs.bundles.room) implementation(libs.androidx.paging.runtime) + implementation(libs.compose.paging) compileOnly(project(":apps:flipcash:shared:persistence:db")) implementation(project(":apps:flipcash:shared:persistence:sources")) + implementation(project(":apps:flipcash:shared:common-ui")) implementation(project(":services:flipcash")) implementation(project(":libs:datetime")) + + testImplementation(libs.bundles.unit.testing) } diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt index fd74e9bf85..d3e984632c 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt @@ -4,59 +4,95 @@ import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData +import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map import com.flipcash.app.core.feed.ActivityFeedMessage import com.flipcash.app.core.feed.ActivityFeedMessageWithToken +import com.flipcash.app.core.feed.MessageMetadata import com.flipcash.app.persistence.sources.MessageDataSource +import com.flipcash.app.persistence.sources.UserProfileDataSource import com.flipcash.app.persistence.sources.mapper.notifications.MessageEntityToFeedMessageMapper import com.flipcash.app.persistence.sources.mediator.FeedRemoteMediator import com.flipcash.services.controllers.ActivityFeedController +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.ActivityFeedNotification import com.flipcash.services.models.ActivityFeedType +import com.flipcash.services.models.NotificationMetadata import com.flipcash.services.models.NotificationState import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.UserProfile import com.flipcash.services.user.UserManager +import com.flipcash.shared.transactionhistory.internal.TransactionItemMapper +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Token import com.getcode.opencode.providers.TokenMetadataProvider import com.getcode.solana.keys.Mint import com.getcode.utils.TraceType +import com.getcode.utils.hexEncodedString import com.getcode.utils.trace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @Singleton -class ActivityFeedCoordinator @Inject constructor( +class ActivityFeedCoordinator @Inject internal constructor( private val activityFeedController: ActivityFeedController, private val dataSource: MessageDataSource, private val mapper: MessageEntityToFeedMessageMapper, private val userManager: UserManager, private val tokenProvider: TokenMetadataProvider, + private val transactionItemMapper: TransactionItemMapper, + private val userProfiles: UserProfileDataSource, + private val profileController: ProfileController, ) { private val pagingConfig = PagingConfig(pageSize = 20) - @OptIn(ExperimentalPagingApi::class) - private val _messages: Flow> = userManager.state - .filter { it.authState.canAccessAuthenticatedApis } + // App-lifetime scope for caching the recent-transactions pages (see recentTransactions). + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + // Counterparty user-ids (hex) currently being resolved over the network, so concurrent misses + // for the same user collapse to a single fetch. + private val resolvingProfiles = ConcurrentHashMap.newKeySet() + + @OptIn(ExperimentalPagingApi::class, ExperimentalCoroutinesApi::class) + val messages: Flow> = userManager.state + // Dedupe the auth gate so the Pager is built ONCE. Without distinctUntilChanged, every + // unrelated userManager.state emission (balance polls, etc.) re-passes the filter and + // flatMapLatest rebuilds the Pager → a REFRESH loop that constantly resets the list and + // locks the UI. (Same guard as BlocklistCoordinator.) The previous nested + // state→flatMapLatest→state→flatMapLatest wrapping doubled the churn; collapsed to one. + .map { it.authState.canAccessAuthenticatedApis } + .distinctUntilChanged() + .filter { it } .flatMapLatest { Pager( config = pagingConfig, - remoteMediator = FeedRemoteMediator(activityFeedController, dataSource) + remoteMediator = FeedRemoteMediator( + activityFeedController, + dataSource, + // Resolve-on-fetch: back-fill counterparty profiles as each page lands. + onFetched = { notifications -> + ensureProfiles(notifications.mapNotNull { counterpartyOf(it.metadata) }) + }, + ) ) { dataSource.observe() }.flow.map { page -> page.map { entity -> mapper.map(entity) } } } - @OptIn(ExperimentalCoroutinesApi::class) - val messages: Flow> = userManager.state - .mapNotNull { it.authState } - .filter { it.canAccessAuthenticatedApis } - .flatMapLatest { _messages } - @OptIn(ExperimentalCoroutinesApi::class) fun transactions(mint: Mint): Flow> = messages.map { page -> page.filter { message -> @@ -67,6 +103,106 @@ class ActivityFeedCoordinator @Inject constructor( } } + /** + * Presentation-ready recent activity across every token, newest first. + * + * Raw message pages are cached in [scope] (so they survive config changes and re-subscription), + * then combined with the observed profile **and** token caches so each row's avatar, title, and + * (for deposits/withdrawals) token icon resolve **synchronously from memory** — no per-item I/O + * in the paging transform. Because the caches are applied *after* [cachedIn], a profile or token + * landing in cache re-maps the visible rows **without reloading** the list. + * + * This is a deliberate replacement for a previous design that called + * `tokenProvider.getTokenMetadata(mint)` per item *inside* the transform. That path hit the + * network for any mint the user held no account for (e.g. a tip in a creator coin) — and since + * such mints are never cached, every page emission re-fetched them, so the wallet feed churned + * network calls and never settled ("locks up when a tip comes into view"). Resolving tokens + * cache-only here removes the churn entirely; a not-yet-cached counterparty/token simply resolves + * later when it lands (or, for an unheld tip coin, stays absent — tips use the profile avatar, + * not the token icon). + * + * NB: we intentionally do NOT `.filter` the PagingData — filtering pages makes the presenter keep + * requesting more pages to fill the viewport (a runaway-load stall). Unresolved rows render with a + * generic avatar + their server-fallback title instead of being dropped. + */ + @OptIn(ExperimentalCoroutinesApi::class) + fun recentTransactions(): Flow> = + messages + .cachedIn(scope) + .combine(resolvers) { paging, (profiles, tokens) -> + paging.map { msg -> + // Resolve-on-miss: a visible row whose counterparty isn't cached triggers a + // background fetch+store; when it lands, observeProfiles re-emits and this row + // re-maps with the resolved avatar + name (no reload). + counterpartyOf(msg.metadata) + ?.takeUnless { profiles.containsKey(it.hexEncodedString()) } + ?.let(::ensureProfile) + val token = msg.amount?.mint?.let { tokens[it] } + transactionItemMapper.map(ActivityFeedMessageWithToken(msg, token) to profiles) + } + } + + /** + * Ensures a cached [UserProfile] exists for each of [userIds], fetching any miss over the network + * and storing it so [UserProfileDataSource.observeProfiles] re-emits. In-flight fetches for the + * same user are de-duplicated. Fire-and-forget on [scope]; failures are left for the next trigger. + */ + private fun ensureProfiles(userIds: Collection) = userIds.forEach(::ensureProfile) + + private fun ensureProfile(userId: ID) { + val hex = userId.hexEncodedString() + if (!resolvingProfiles.add(hex)) return + scope.launch { + try { + if (userProfiles.getCachedProfile(userId) != null) return@launch + profileController.getProfileForUser(userId) + .onSuccess { userProfiles.store(userId, it) } + } finally { + resolvingProfiles.remove(hex) + } + } + } + + private fun counterpartyOf(meta: MessageMetadata?): ID? = when (meta) { + is MessageMetadata.DirectlySentCrypto -> meta.userId + is MessageMetadata.ReceivedCrypto -> meta.userId + else -> null + } + + private fun counterpartyOf(meta: NotificationMetadata?): ID? = when (meta) { + is NotificationMetadata.DirectlySentCrypto -> meta.userId + is NotificationMetadata.ReceivedCrypto -> meta.userId + else -> null + } + + /** + * A **preview** of the [limit] most recent transactions (newest first), presentation-ready. Unlike + * [recentTransactions] this is a bounded, non-paged list for surfaces that show only a glimpse of + * activity (e.g. the wallet screen); the full paged history uses [recentTransactions]. Same live + * resolution as the paged flow: token/profile caches fill avatars, titles, and icons reactively, + * and an uncached counterparty triggers a background fetch (see [ensureProfile]). + */ + fun recentTransactions(limit: Int): Flow> = + combine(dataSource.observeRecent(limit), resolvers) { messages, (profiles, tokens) -> + messages.map { msg -> + counterpartyOf(msg.metadata) + ?.takeUnless { profiles.containsKey(it.hexEncodedString()) } + ?.let(::ensureProfile) + val token = msg.amount?.mint?.let { tokens[it] } + transactionItemMapper.map(ActivityFeedMessageWithToken(msg, token) to profiles) + } + } + + /** + * Observed profile + token caches, paired for a single [combine] against the cached pages. Both + * are network-free reads that re-emit as their caches hydrate, so rows resolve reactively from + * memory. Started eagerly-cold: it only collects while [recentTransactions] is subscribed. + */ + private val resolvers: Flow, Map>> = + combine(userProfiles.observeProfiles(), tokenProvider.observeTokenCache()) { profiles, tokens -> + profiles to tokens + } + /** Reactive "has the user ever added money" — any completed deposit/buy in the feed. */ fun hasEverAddedMoney(): Flow = dataSource.hasEverAddedMoney() diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedRow.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedRow.kt new file mode 100644 index 0000000000..78c8e7cee2 --- /dev/null +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedRow.kt @@ -0,0 +1,110 @@ +package com.flipcash.shared.transactionhistory + +import android.text.format.DateFormat +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.ui.TokenIcon +import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.services.models.UserProfile +import com.getcode.theme.CodeTheme +import com.getcode.util.formatLocalized +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * A single row in the "Recent" activity list on the Wallet screen (Figma 8966:1910). + * + * Layout: [40dp avatar] · [title + relative time (weight 1)] · [signed amount] + */ +@Composable +fun ActivityFeedRow( + item: TransactionListItem, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = CodeTheme.dimens.grid.x2), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.CenterVertically, + ) { + val avatarModifier = Modifier + .requiredSize(CodeTheme.dimens.staticGrid.x8) + .clip(CircleShape) + + when (val a = item.avatar) { + is TransactionAvatar.Profile -> + ContactAvatar(userProfile = a.profile, modifier = avatarModifier) + is TransactionAvatar.TokenIcon -> + TokenIcon(token = a.token, modifier = avatarModifier) + TransactionAvatar.Generic -> + ContactAvatar(userProfile = UserProfile.Empty, modifier = avatarModifier) + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Text( + text = item.title, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = formatActivityTimestamp(item.timestamp), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + + item.amount?.let { fiat -> + Text( + text = fiat.formatted(extraPrefix = item.signedAmountPrefix?.ifEmpty { null }), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + } + } +} + +/** + * Returns a human-readable relative timestamp, matching the behaviour of + * `formatLastActivity` in `:shared:chat-ui` (but without depending on that module, + * which would create a circular dep via chat → tokens → transaction-history). + */ +@Composable +private fun formatActivityTimestamp(instant: Instant): String { + val context = LocalContext.current + val is24Hour = DateFormat.is24HourFormat(context) + val tz = TimeZone.currentSystemDefault() + val todayDate = Clock.System.now().toLocalDateTime(tz).date + val messageDate = instant.toLocalDateTime(tz).date + val dayDiff = todayDate.toEpochDays() - messageDate.toEpochDays() + val time = instant.formatLocalized("h:mm a", is24Hour = is24Hour, if24Hour = "H:mm") + return when { + dayDiff == 0L -> time + dayDiff == 1L -> stringResource(R.string.label_chatReceipt_yesterday) + dayDiff in 2L..6L -> instant.formatLocalized("EEEE") + messageDate.year == todayDate.year -> instant.formatLocalized("MMM d") + else -> instant.formatLocalized("MMM d, yyyy") + } +} diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt new file mode 100644 index 0000000000..8d2c2b3f84 --- /dev/null +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt @@ -0,0 +1,28 @@ +package com.flipcash.shared.transactionhistory + +import com.flipcash.services.models.UserProfile +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Token +import kotlin.time.Instant + +/** + * Leading avatar for a transaction row. + * - [Profile] — a resolved counterparty (tip / user-to-user send-receive), keyed by user id. + * - [TokenIcon] — no counterparty (deposit / buy / sell / withdraw): the token's icon. + * - [Generic] — unresolved / unknown counterparty. + */ +sealed interface TransactionAvatar { + data class Profile(val profile: UserProfile) : TransactionAvatar + data class TokenIcon(val token: Token) : TransactionAvatar + data object Generic : TransactionAvatar +} + +data class TransactionListItem( + val id: String, // stable paging key (message.id hex-encoded) + val title: String, // server-provided message.text + val timestamp: Instant, + val avatar: TransactionAvatar, + val signedAmountPrefix: String?, // "-", "+", or null (for metadata == null) + val amount: Fiat?, // message.amount.nativeAmount, or null if non-financial + val canCancel: Boolean, +) diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt new file mode 100644 index 0000000000..0e91941194 --- /dev/null +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt @@ -0,0 +1,136 @@ +package com.flipcash.shared.transactionhistory.internal + +import com.flipcash.app.core.feed.ActivityFeedMessageWithToken +import com.flipcash.app.core.feed.MessageMetadata +import com.flipcash.app.core.feed.MessageSubstitution +import com.flipcash.services.models.UserProfile +import com.flipcash.shared.transactionhistory.TransactionAvatar +import com.flipcash.shared.transactionhistory.TransactionListItem +import com.getcode.opencode.mapper.Mapper +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.Mint +import com.getcode.utils.hexEncodedString +import javax.inject.Inject + +/** + * Maps a feed item to a presentation item, resolving the counterparty avatar and title from the + * observed [profiles] cache (keyed by user-id hex). Pure and synchronous — no I/O — so it is safe + * inside the paging transform; profiles arrive reactively as the cache is observed, so a + * not-yet-cached counterparty simply resolves later when its profile lands. + */ +internal class TransactionItemMapper @Inject constructor(): Mapper>, TransactionListItem> { + override fun map(from: Pair>): TransactionListItem { + val (source, profiles) = from + val msg = source.message + val meta = msg.metadata + val token = source.token + + val counterparty = userIdOf(meta)?.let { profiles[it.hexEncodedString()] } + val avatar: TransactionAvatar = when { + counterparty != null -> TransactionAvatar.Profile(counterparty) + hasNoCounterparty(meta) && token != null -> TransactionAvatar.TokenIcon(token) + else -> TransactionAvatar.Generic + } + + val prefix: String? = when { + meta == null -> null + meta.isOutgoing -> "-" + else -> "+" + } + + return TransactionListItem( + // Full hex of the message id — NOT `id.uuid.toString()`: `ID.uuid` is null for any id + // that isn't exactly 16 bytes (activity ids aren't), so uuid.toString() collapses EVERY + // row to the literal key "null". Duplicate keys wedge the LazyColumn under the app's + // SharedTransitionLayout lookahead (whole-app freeze as a duplicate-keyed row scrolls in). + id = msg.id.hexEncodedString(), + title = resolveTitle(meta, msg.text, msg.textSubstitutions, counterparty, token, profiles), + timestamp = msg.timestamp, + avatar = avatar, + signedAmountPrefix = prefix, + amount = msg.amount?.nativeAmount, + canCancel = (meta as? MessageMetadata.IndirectlySentCrypto)?.canCancel == true, + ) + } +} + +/** + * Resolves the row title. + * + * When the server sends indexed placeholders ({0}, {1}, …) it fills them from [substitutions], + * preferring the observed display name for `UserId` substitutions (server + * [MessageSubstitution.fallback] otherwise). + * + * Otherwise the server sends a bare verb (e.g. "Tipped", "Purchased", "Received") and the client + * completes it with the relevant subject, resolved reactively (the bare verb shows until it lands): + * - buys/sells append the **token** name — "Purchased Dad Cash", "Sold Dad Cash"; + * - received tips read "Received Tip From "; + * - tips/sends and anything else with a counterparty append the **counterparty** name — "Tipped Sally". + */ +private fun resolveTitle( + meta: MessageMetadata?, + text: String, + substitutions: List, + counterparty: UserProfile?, + token: Token?, + profiles: Map, +): String { + if (substitutions.isNotEmpty()) { + var result = text + substitutions.forEachIndexed { index, substitution -> + val name = when (substitution) { + is MessageSubstitution.UserId -> + profiles[substitution.userId.hexEncodedString()]?.displayName?.takeIf { it.isNotBlank() } + ?: substitution.fallback + is MessageSubstitution.Phone -> substitution.fallback + } + result = result.replace("{$index}", name) + } + return result + } + + val counterpartyName = counterparty?.displayName?.takeIf { it.isNotBlank() } + val tokenName = token?.name?.takeIf { it.isNotBlank() } ?: token?.symbol?.takeIf { it.isNotBlank() } + return when (meta) { + is MessageMetadata.ReceivedCrypto -> + if (counterpartyName != null) "$text Tip From $counterpartyName" else text + MessageMetadata.BoughtToken -> + // Buying dollars (USDF — the only buyable stablecoin) is just adding money — read it as + // such, not "Purchased Dollars". + if (token?.address == Mint.usdf) "Added Money" + else if (tokenName != null) "$text $tokenName" else text + MessageMetadata.SoldToken -> + if (tokenName != null) "$text $tokenName" else text + else -> + if (counterpartyName != null) "$text $counterpartyName" else text + } +} + +private fun userIdOf(meta: MessageMetadata?): ID? = when (meta) { + is MessageMetadata.DirectlySentCrypto -> meta.userId + is MessageMetadata.ReceivedCrypto -> meta.userId + else -> null +} + +private fun hasNoCounterparty(meta: MessageMetadata?): Boolean = when (meta) { + MessageMetadata.DepositedCrypto, + MessageMetadata.WithdrewCrypto, + MessageMetadata.BoughtToken, + MessageMetadata.SoldToken -> true + else -> false +} + +/** Whether an entry debits the user (show a "-" and, if desired, a debit treatment). */ +val MessageMetadata.isOutgoing: Boolean + get() = when (this) { + is MessageMetadata.DirectlySentCrypto, + is MessageMetadata.IndirectlySentCrypto, + MessageMetadata.WithdrewCrypto, + MessageMetadata.SoldToken, + is MessageMetadata.PaidCrypto -> true + is MessageMetadata.ReceivedCrypto, + MessageMetadata.DepositedCrypto, + MessageMetadata.BoughtToken, + MessageMetadata.Unknown -> false + } diff --git a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/MessageDirectionTest.kt b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/MessageDirectionTest.kt new file mode 100644 index 0000000000..656fedf481 --- /dev/null +++ b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/MessageDirectionTest.kt @@ -0,0 +1,31 @@ +package com.flipcash.shared.transactionhistory + +import com.flipcash.app.core.feed.MessageMetadata +import com.flipcash.shared.transactionhistory.internal.isOutgoing +import com.getcode.solana.keys.PublicKey +import org.junit.Assert.assertEquals +import org.junit.Test + +class MessageDirectionTest { + + @Test + fun `sent variants are outgoing`() { + assertEquals(true, MessageMetadata.DirectlySentCrypto(phoneNumber = null).isOutgoing) + assertEquals(true, MessageMetadata.IndirectlySentCrypto(PublicKey(ByteArray(32).toList()), canCancel = true).isOutgoing) + assertEquals(true, MessageMetadata.WithdrewCrypto.isOutgoing) + assertEquals(true, MessageMetadata.SoldToken.isOutgoing) + assertEquals(true, MessageMetadata.PaidCrypto(poolId = listOf()).isOutgoing) + } + + @Test + fun `received variants are incoming`() { + assertEquals(false, MessageMetadata.ReceivedCrypto(phoneNumber = null).isOutgoing) + assertEquals(false, MessageMetadata.DepositedCrypto.isOutgoing) + assertEquals(false, MessageMetadata.BoughtToken.isOutgoing) + } + + @Test + fun `unknown is not outgoing`() { + assertEquals(false, MessageMetadata.Unknown.isOutgoing) + } +} diff --git a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt new file mode 100644 index 0000000000..6626e85b81 --- /dev/null +++ b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt @@ -0,0 +1,227 @@ +package com.flipcash.shared.transactionhistory + +import com.flipcash.app.core.feed.ActivityFeedMessage +import com.flipcash.app.core.feed.ActivityFeedMessageWithToken +import com.flipcash.app.core.feed.MessageMetadata +import com.flipcash.app.core.feed.MessageState +import com.flipcash.app.core.feed.MessageSubstitution +import com.flipcash.services.models.UserProfile +import com.flipcash.shared.transactionhistory.internal.TransactionItemMapper +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.HolderMetrics +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.MintMetadata +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.VmMetadata +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import com.getcode.utils.hexEncodedString +import kotlin.time.Instant +import org.junit.Assert.assertEquals +import org.junit.Test + +class TransactionItemMapperTest { + + private val mapper = TransactionItemMapper() + + private val knownUserId: ID = listOf(0x0A, 0x0B, 0x0C) + private val knownProfile = UserProfile.Empty.copy(displayName = "Sally The Streamer") + private val cached: Map = mapOf(knownUserId.hexEncodedString() to knownProfile) + + private fun feedMessage( + metadata: MessageMetadata?, + amountUsd: Double = 20.0, + ): ActivityFeedMessage = ActivityFeedMessage( + id = listOf(0x01, 0x02, 0x03).map { it.toByte() }, + text = "Tipped Sally The Streamer", + amount = LocalFiat( + usdf = Fiat(amountUsd, CurrencyCode.USD), + nativeAmount = Fiat(amountUsd, CurrencyCode.USD), + ), + timestamp = Instant.fromEpochSeconds(1700000000L), + state = MessageState.COMPLETED, + metadata = metadata, + ) + + private fun usdfToken(): Token = token(address = Mint.usdf, name = "USDF", symbol = "USDF") + + private fun token(address: Mint, name: String, symbol: String): Token = MintMetadata( + address = address, + decimals = 6, + name = name, + symbol = symbol, + createdAt = null, + description = "", + imageUrl = "", + vmMetadata = VmMetadata( + vm = PublicKey.fromBase58("11111111111111111111111111111111"), + authority = PublicKey.fromBase58("11111111111111111111111111111111"), + lockDurationInDays = 21, + ), + launchpadMetadata = null, + billCustomizations = null, + socialLinks = emptyList(), + holderMetrics = HolderMetrics.None, + ) + + @Test + fun `send to a cached user resolves a profile avatar and a minus prefix`() { + val msg = feedMessage(metadata = MessageMetadata.DirectlySentCrypto(userId = knownUserId)) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to cached) + + assertEquals("-", item.signedAmountPrefix) + assertEquals(TransactionAvatar.Profile(knownProfile), item.avatar) + } + + @Test + fun `receive from a cached user resolves a profile avatar and a plus prefix`() { + val msg = feedMessage(metadata = MessageMetadata.ReceivedCrypto(userId = knownUserId)) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to cached) + + assertEquals("+", item.signedAmountPrefix) + assertEquals(TransactionAvatar.Profile(knownProfile), item.avatar) + } + + @Test + fun `unresolved counterparty falls back to a generic avatar`() { + val msg = feedMessage(metadata = MessageMetadata.DirectlySentCrypto(userId = knownUserId)) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to emptyMap()) + + assertEquals(TransactionAvatar.Generic, item.avatar) + assertEquals("-", item.signedAmountPrefix) + } + + @Test + fun `bare verb title appends the resolved counterparty name`() { + val msg = feedMessage(metadata = MessageMetadata.DirectlySentCrypto(userId = knownUserId)) + .copy(text = "Tipped", textSubstitutions = emptyList()) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to cached) + + assertEquals("Tipped Sally The Streamer", item.title) + } + + @Test + fun `bare verb title stays bare until the counterparty profile resolves`() { + val msg = feedMessage(metadata = MessageMetadata.DirectlySentCrypto(userId = knownUserId)) + .copy(text = "Tipped", textSubstitutions = emptyList()) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to emptyMap()) + + assertEquals("Tipped", item.title) + } + + @Test + fun `received tip reads Received Tip From the counterparty`() { + val msg = feedMessage(metadata = MessageMetadata.ReceivedCrypto(userId = knownUserId)) + .copy(text = "Received", textSubstitutions = emptyList()) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to cached) + + assertEquals("Received Tip From Sally The Streamer", item.title) + } + + @Test + fun `bought token appends the token name`() { + val token = token(address = Mint.usdc, name = "Dad Cash", symbol = "DADCASH") + val msg = feedMessage(metadata = MessageMetadata.BoughtToken) + .copy(text = "Purchased", textSubstitutions = emptyList()) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token) to emptyMap()) + + assertEquals("Purchased Dad Cash", item.title) + } + + @Test + fun `bought dollars reads Added Money instead of Purchased Dollars`() { + val msg = feedMessage(metadata = MessageMetadata.BoughtToken) + .copy(text = "Purchased", textSubstitutions = emptyList()) + val item = mapper.map(ActivityFeedMessageWithToken(msg, usdfToken()) to emptyMap()) + + assertEquals("Added Money", item.title) + } + + @Test + fun `sold token appends the token name`() { + val token = token(address = Mint.usdc, name = "Dad Cash", symbol = "DADCASH") + val msg = feedMessage(metadata = MessageMetadata.SoldToken) + .copy(text = "Sold", textSubstitutions = emptyList()) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token) to emptyMap()) + + assertEquals("Sold Dad Cash", item.title) + } + + @Test + fun `bought token stays bare until token metadata resolves`() { + val msg = feedMessage(metadata = MessageMetadata.BoughtToken) + .copy(text = "Purchased", textSubstitutions = emptyList()) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to emptyMap()) + + assertEquals("Purchased", item.title) + } + + @Test + fun `title placeholder resolves to the observed name over the fallback`() { + val msg = feedMessage(metadata = MessageMetadata.DirectlySentCrypto(userId = knownUserId)) + .copy( + text = "Tipped {0}", + textSubstitutions = listOf( + MessageSubstitution.UserId(fallback = "Stale Name", userId = knownUserId), + ), + ) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to cached) + + assertEquals("Tipped Sally The Streamer", item.title) + } + + @Test + fun `title placeholder falls back to the server name when unresolved`() { + val msg = feedMessage(metadata = MessageMetadata.DirectlySentCrypto(userId = knownUserId)) + .copy( + text = "Tipped {0}", + textSubstitutions = listOf( + MessageSubstitution.UserId(fallback = "Server Sally", userId = knownUserId), + ), + ) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to emptyMap()) + + assertEquals("Tipped Server Sally", item.title) + } + + @Test + fun `deposit uses the token icon and a plus prefix`() { + val token = usdfToken() + val msg = feedMessage(metadata = MessageMetadata.DepositedCrypto, amountUsd = 30.0) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token) to emptyMap()) + + assertEquals("+", item.signedAmountPrefix) + assertEquals(TransactionAvatar.TokenIcon(token), item.avatar) + } + + @Test + fun `withdraw uses the token icon and a minus prefix`() { + val token = usdfToken() + val msg = feedMessage(metadata = MessageMetadata.WithdrewCrypto) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token) to emptyMap()) + + assertEquals("-", item.signedAmountPrefix) + assertEquals(TransactionAvatar.TokenIcon(token), item.avatar) + } + + @Test + fun `null metadata yields null prefix and a generic avatar`() { + val msg = feedMessage(metadata = null) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to emptyMap()) + + assertEquals(null, item.signedAmountPrefix) + assertEquals(TransactionAvatar.Generic, item.avatar) + } + + @Test + fun `canCancel is true for indirectly sent with the canCancel flag`() { + val creator = PublicKey(ByteArray(32).toList()) + val msg = feedMessage(metadata = MessageMetadata.IndirectlySentCrypto(creator, canCancel = true)) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to emptyMap()) + + assertEquals(true, item.canCancel) + assertEquals("-", item.signedAmountPrefix) + } +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/providers/TokenMetadataProvider.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/providers/TokenMetadataProvider.kt index 73632d817b..acb11fc686 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/providers/TokenMetadataProvider.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/providers/TokenMetadataProvider.kt @@ -1,7 +1,9 @@ package com.getcode.opencode.providers +import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.TokenResult import com.getcode.solana.keys.Mint +import kotlinx.coroutines.flow.Flow /** * Provides token metadata resolution with cache-through semantics. @@ -12,4 +14,13 @@ import com.getcode.solana.keys.Mint */ interface TokenMetadataProvider { suspend fun getTokenMetadata(mint: Mint): Result + + /** + * Observes the in-memory token cache as a `mint -> `[Token] map, re-emitting as tokens are + * hydrated. Unlike [getTokenMetadata] this NEVER hits the network — it is a pure read of what + * is already cached, so it is safe to resolve tokens synchronously inside hot paths (e.g. a + * paging transform) without triggering per-item fetches. Mints the user holds no account for + * are simply absent (they are never cached) rather than fetched on demand. + */ + fun observeTokenCache(): Flow> } \ No newline at end of file