From 020c1ef27f0c13a472fc45d4aa662c845130c217 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 4 Sep 2026 11:42:32 -0400 Subject: [PATCH] feat(activity): open a transaction's details from any activity row An activity row states what moved and when, and nothing else. Tapping one did nothing, so the exchange rate, the token quantity, the fee, and the settlement state had nowhere to be read, and a sent cash link could only be cancelled from the row's own swipe action. Rows in the wallet preview, the token-info preview and the full activity history now push `AppRoute.Sheets.TransactionDetails(messageId)`, which draws the same entry as a screen: heading and avatar, the signed amount, and a receipt of the values a row has no space for. `TransactionDetailsMapper` shares `TransactionItemMapper`'s reading of the metadata (counterparty, avatar, direction), so a row and the screen it opens cannot disagree about what the entry was. What it adds is the kind stated in the user's own voice, the receipt values, and the two actions. `ResolvedTransaction` carries the drawn state and the action targets in one emission: cancelling needs `IndirectlySentCrypto.creator`, and opening the conversation needs the user id plus the profile, neither of which belongs in a UI model. The screen reads through `MessageDao.observeMessageById`, so cancelling a cash link from the app bar redraws the screen once the update lands rather than leaving a stale "Pending". `TransactionDetailsViewModel` lives in `:features:transactions` rather than beside the mapper: cancelling goes through `TokenCoordinator`, and `:shared:transaction-history` depending on `:shared:tokens` closes a cycle through chat. Two receipt rows are built but unreachable from real data. Neither the message metadata nor the notification carries a withdrawal destination or a deposit source, so `account` is always null and the To/From row never renders; and a legacy buy/sell records only the mint that moved, so its subtitle has no counterpart mint to name. Both are left in place for when the server sends them. The token quantity is an estimate: `estimatedTokenAmountIn` prices against the mint's current supply, so on a historical entry it says what that value is worth now, not what it bought then. --- .../ui/navigation/AppScreenContent.kt | 2 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 11 + .../core/src/main/res/values/strings.xml | 1 + .../balance/internal/WalletScreenContent.kt | 5 + .../internal/WalletLoadingStateTest.kt | 1 + .../components/info/CurrencyInfoContentV2.kt | 7 + .../transactions/TransactionDetailsScreen.kt | 63 ++++ .../internal/TransactionDetailsViewModel.kt | 168 +++++++++ .../app/persistence/dao/MessageDao.kt | 13 + .../persistence/sources/MessageDataSource.kt | 13 + .../transaction-history/build.gradle.kts | 5 + .../ActivityFeedCoordinator.kt | 55 +++ .../transactionhistory/ActivityFeedRow.kt | 106 +----- .../ActivityHistoryScreen.kt | 7 +- .../RecentActivitySection.kt | 3 + .../transactionhistory/ResolvedTransaction.kt | 25 ++ .../TransactionAvatarImage.kt | 144 ++++++++ .../transactionhistory/TransactionDetails.kt | 128 +++++++ .../TransactionDetailsContent.kt | 339 ++++++++++++++++++ .../transactionhistory/TransactionListItem.kt | 4 + .../TransactionSubtitles.kt | 49 +++ .../internal/TransactionDetailsMapper.kt | 182 ++++++++++ .../internal/TransactionItemMapper.kt | 9 +- .../src/main/res/values/strings.xml | 46 +++ .../TransactionDetailsMapperTest.kt | 225 ++++++++++++ .../TransactionDetailsSamples.kt | 220 ++++++++++++ .../TransactionDetailsScreenshotTest.kt | 142 ++++++++ .../src/test/resources/tokens/dollars.webp | Bin 0 -> 18782 bytes .../src/test/resources/tokens/jeffy.png | Bin 0 -> 7418 bytes 29 files changed, 1872 insertions(+), 101 deletions(-) create mode 100644 apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/TransactionDetailsScreen.kt create mode 100644 apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModel.kt create mode 100644 apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ResolvedTransaction.kt create mode 100644 apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionAvatarImage.kt create mode 100644 apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetails.kt create mode 100644 apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsContent.kt create mode 100644 apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionSubtitles.kt create mode 100644 apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt create mode 100644 apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt create mode 100644 apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsSamples.kt create mode 100644 apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsScreenshotTest.kt create mode 100644 apps/flipcash/shared/transaction-history/src/test/resources/tokens/dollars.webp create mode 100644 apps/flipcash/shared/transaction-history/src/test/resources/tokens/jeffy.png diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 2de025885..7294ef6f5 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -49,6 +49,7 @@ import com.flipcash.app.tokens.SwapFlowScreen import com.flipcash.app.tokens.TokenInfoScreen import com.flipcash.app.tokens.TokenSelectScreen +import com.flipcash.app.transactions.TransactionDetailsScreen import com.flipcash.app.transactions.TransactionHistoryScreen import com.flipcash.app.userflags.UserFlagsScreen import com.flipcash.app.userprofile.UpdateUserProfileFlowScreen @@ -105,6 +106,7 @@ fun appEntryProvider( } annotatedEntry { ShareAppScreen() } annotatedEntry { ActivityHistoryScreen() } + annotatedEntry { key -> TransactionDetailsScreen(key.id) } annotatedEntry { MenuScreen() } // Messaging diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 8bb6dfe4f..1fae42a78 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -24,6 +24,7 @@ import com.flipcash.app.core.withdrawal.WithdrawalStep import com.getcode.navigation.flow.FlowRoute import com.getcode.navigation.flow.FlowRouteWithResult import com.getcode.navigation.flow.FlowStep +import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.Mint import com.getcode.ui.core.RestrictionType @@ -186,6 +187,16 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data object ActivityHistory : Sheets + /** + * One activity entry, opened from its row (Figma node 9708:105260). + * + * Carries the entry's id rather than the row that was tapped: the screen re-reads the entry + * and stays live on it, so a cash link cancelled from this screen's own app bar redraws the + * screen the cancel was issued from. + */ + @Serializable + data class TransactionDetails(val id: ID) : Sheets + @Serializable data object Menu : Sheets diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 625dacaa7..ca298daae 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -170,6 +170,7 @@ Deposit Address Public Key Account ID + Transaction ID Push Token Your Access Key will grant access to your Flipcash account. Keep it private and safe 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 93e4ad275..7cc0bc9a7 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 @@ -272,6 +272,11 @@ internal fun WalletScreenContent( dispatchEvent(WalletViewModel.Event.OpenScreen(AppRoute.Sheets.ActivityHistory)) } .padding(top = grid.x4, bottom = grid.x1), + onItemClick = { item -> + dispatchEvent( + WalletViewModel.Event.OpenScreen(AppRoute.Sheets.TransactionDetails(item.messageId)) + ) + }, ) } diff --git a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt index e404126fa..ac5cf1ac4 100644 --- a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt +++ b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt @@ -23,6 +23,7 @@ class WalletLoadingStateTest { private val aTransaction = TransactionListItem( id = "1", + messageId = listOf(1), title = "Received", timestamp = Instant.fromEpochSeconds(0), avatar = TransactionAvatar.Generic(), diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt index dbf290abb..f8ec1889f 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt @@ -239,6 +239,13 @@ internal fun CurrencyInfoContentV2( .padding(horizontal = inset) .padding(top = grid.x5, bottom = grid.x1), itemPadding = PaddingValues(horizontal = inset), + onItemClick = { item -> + dispatch( + TokenInfoViewModel.Event.OpenScreen( + AppRoute.Sheets.TransactionDetails(item.messageId) + ) + ) + }, ) } diff --git a/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/TransactionDetailsScreen.kt b/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/TransactionDetailsScreen.kt new file mode 100644 index 000000000..9deac1077 --- /dev/null +++ b/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/TransactionDetailsScreen.kt @@ -0,0 +1,63 @@ +package com.flipcash.app.transactions + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.transactions.internal.TransactionDetailsViewModel +import com.flipcash.shared.transactionhistory.TransactionDetailsContent +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.opencode.model.core.ID +import com.getcode.theme.CodeTheme + +/** + * One activity entry, opened from its row (Figma node 9708:105260). + * + * Lives here rather than beside [TransactionDetailsContent] in `:shared:transaction-history` because + * of the cancel action: pulling a cash link back needs `TokenCoordinator` and `TransactionOperations` + * from `:shared:tokens`, which that module can't depend on without closing a cycle (chat → tokens → + * transaction-history). The drawing stays shared; only the wiring is here. + */ +@Composable +fun TransactionDetailsScreen(id: ID) { + val navigator = LocalCodeNavigator.current + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + LaunchedEffect(viewModel, id) { + viewModel.dispatchEvent(TransactionDetailsViewModel.Event.OnIdProvided(id)) + } + + val transaction = state.transaction + if (transaction == null) { + // Nothing to draw yet — a cached entry resolves on the first read, so this is a frame, not a + // state worth an empty message. + Box( + modifier = Modifier + .fillMaxSize() + .background(CodeTheme.colors.background) + ) + return + } + + TransactionDetailsContent( + details = transaction.details, + onBack = { navigator.pop() }, + onCopyId = { viewModel.dispatchEvent(TransactionDetailsViewModel.Event.CopyId) }, + onViewInChat = { + val userId = transaction.counterpartyId ?: return@TransactionDetailsContent + val profile = transaction.counterparty ?: return@TransactionDetailsContent + navigator.push( + AppRoute.Messaging.Chat(ChatIdentifier.ByUser(userId = userId, profile = profile)) + ) + }, + onCancel = { viewModel.dispatchEvent(TransactionDetailsViewModel.Event.OnCancelRequested) }, + ) +} diff --git a/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModel.kt b/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModel.kt new file mode 100644 index 000000000..15cb2d678 --- /dev/null +++ b/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModel.kt @@ -0,0 +1,168 @@ +package com.flipcash.app.transactions.internal + +import android.content.ClipboardManager +import androidx.lifecycle.viewModelScope +import com.flipcash.app.core.extensions.onResult +import com.flipcash.app.core.extensions.setText +import com.flipcash.app.core.feed.MessageMetadata +import com.flipcash.app.core.money.formatted +import com.flipcash.app.core.toast.SystemToastController +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.features.transactions.R +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.user.UserManager +import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator +import com.flipcash.shared.transactionhistory.ResolvedTransaction +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.controllers.TransactionOperations +import com.getcode.opencode.model.core.ID +import com.getcode.solana.keys.PublicKey +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The details screen for one activity entry. + * + * Holds an *observation* of the entry rather than a snapshot of it, because the screen outlives the + * state it opened on: cancelling a cash link from this screen's own app bar completes as a feed + * update, and the screen has to redraw from that. The same subscription is what fills in a + * counterparty or a mint whose metadata lands after the screen is already up. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class TransactionDetailsViewModel @Inject constructor( + feedCoordinator: ActivityFeedCoordinator, + tokenCoordinator: TokenCoordinator, + transactionController: TransactionOperations, + clipboardManager: ClipboardManager, + toastController: SystemToastController, + userManager: UserManager, + resources: ResourceHelper, + dispatchers: DispatcherProvider, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, +) { + + /** + * @param transaction The entry as drawn, null until the first read lands — which is also what + * an id with nothing cached behind it stays at. + */ + data class State( + val id: ID? = null, + val transaction: ResolvedTransaction? = null, + ) + + sealed interface Event { + data class OnIdProvided(val id: ID) : Event + data class OnTransactionResolved(val transaction: ResolvedTransaction?) : Event + data object CopyId : Event + data object OnCancelRequested : Event + data class CancelTransfer(val vault: PublicKey) : Event + } + + init { + stateFlow.mapNotNull { it.id } + .distinctUntilChanged() + .flatMapLatest { feedCoordinator.transactionDetails(it) } + .onEach { dispatchEvent(Event.OnTransactionResolved(it)) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { stateFlow.value.transaction?.details?.id } + .onEach { id -> + clipboardManager.setText( + text = id, + label = resources.getString(R.string.title_clipboardLabelTransactionId), + ) + toastController.showToast(R.string.action_copied, replacePrevious = true) + }.launchIn(viewModelScope) + + // Same confirm-then-cancel flow the history list uses (see TransactionHistoryViewModel): the + // money sits in a gift-card vault until somebody opens the link, and the vault is the only + // handle on it. + eventFlow + .filterIsInstance() + .mapNotNull { stateFlow.value.transaction?.message } + .onEach { message -> + val metadata = message.metadata as? MessageMetadata.IndirectlySentCrypto ?: return@onEach + val formattedAmount = message.amount?.formatted() + val title = formattedAmount?.let { + resources.getString(R.string.prompt_title_cancelTransferWithAmount, it) + } ?: resources.getString(R.string.prompt_title_cancelTransferNoAmount) + BottomBarManager.showAlert( + title = title, + message = resources.getString(R.string.prompt_description_cancelTransfer), + showScrim = true, + showCancel = false, + actions = buildList { + add( + BottomBarAction( + style = BottomBarManager.BottomBarButtonStyle.Filled, + text = resources.getString(R.string.action_cancelTransfer), + ) { + dispatchEvent(Event.CancelTransfer(vault = metadata.creator)) + } + ) + + add( + BottomBarAction( + style = BottomBarManager.BottomBarButtonStyle.Text, + text = resources.getString(R.string.action_nevermind), + ) + ) + }, + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { event -> + val owner = userManager.accountCluster ?: return@mapNotNull null + transactionController.cancelRemoteSend( + vault = event.vault, + owner = owner, + ) + }.onResult( + onError = { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToCancelTransfer), + message = resources.getString(R.string.error_description_failedToCancelTransfer), + ) + }, + onSuccess = { + // The screen stays open on the cancelled entry, so it needs the entry's new + // state written to the cache — the observation above redraws from it. + viewModelScope.launch { + feedCoordinator.checkPendingMessagesForUpdates() + tokenCoordinator.update() + } + } + ).launchIn(viewModelScope) + } + + internal companion object { + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + is Event.OnIdProvided -> { state -> state.copy(id = event.id) } + is Event.OnTransactionResolved -> { state -> state.copy(transaction = event.transaction) } + Event.CopyId -> { state -> state } + Event.OnCancelRequested -> { state -> state } + is Event.CancelTransfer -> { state -> state } + } + } + } +} 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 ade4b0fe4..6b9bc75c0 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 @@ -20,6 +20,19 @@ interface MessageDao { return getMessageById(id.base58) } + /** + * One message, observed — the transaction details screen, which stays open across the entry's + * own state changes: a cash link cancelled from the screen's own app bar completes as a feed + * update, and the screen has to redraw from it rather than from what it opened on. + * + * Emits null while the id isn't cached, which is the same thing the screen shows before the + * first read lands. + */ + @Query("SELECT * FROM messages WHERE idBase58 = :idBase58") + fun observeMessageById(idBase58: String): Flow + + fun observeMessageById(id: List): Flow = observeMessageById(id.base58) + @RawQuery suspend fun queryDirectly(query: SupportSQLiteQuery): List 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 07ca82775..ad35b8678 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 @@ -35,6 +35,19 @@ class MessageDataSource @Inject constructor( return messageEntityMapper.map(result) } + /** + * Observes a single message as a domain model, null while the id isn't cached. Same + * DB-readiness handling as [observeRecent] — the per-user DB is created at login, after + * singletons have built their flow graphs. + */ + @OptIn(ExperimentalCoroutinesApi::class) + fun observeById(id: ID): Flow = + FlipcashDatabase.observeInstance().flatMapLatest { database -> + database?.messageDao()?.observeMessageById(id)?.map { entity -> + entity?.let { messageEntityMapper.map(it) } + } ?: flowOf(null) + } + override suspend fun get(): List { val result = db?.messageDao()?.getAllMessages() ?: return emptyList() return result.map { messageEntityMapper.map(it) } diff --git a/apps/flipcash/shared/transaction-history/build.gradle.kts b/apps/flipcash/shared/transaction-history/build.gradle.kts index 519e6aa0c..e79a44316 100644 --- a/apps/flipcash/shared/transaction-history/build.gradle.kts +++ b/apps/flipcash/shared/transaction-history/build.gradle.kts @@ -21,5 +21,10 @@ dependencies { implementation(project(":libs:datetime")) testImplementation(libs.bundles.unit.testing) + testImplementation(libs.robolectric) testImplementation(testFixtures(project(":ui:resources"))) + // Screenshot renders only: FlipcashPreview for the theme, ExchangeStub for the currency flag + // LocalExchange would otherwise resolve to ExchangeNull. + testImplementation(project(":apps:flipcash:shared:theme")) + testImplementation(project(":services:opencode-compose")) } 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 30a37f81a..e79d6dd67 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 @@ -23,6 +23,7 @@ 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.TransactionDetailsMapper import com.flipcash.shared.transactionhistory.internal.TransactionItemMapper import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Token @@ -79,6 +80,7 @@ class ActivityFeedCoordinator @Inject internal constructor( private val userManager: UserManager, private val tokenProvider: TokenMetadataProvider, private val transactionItemMapper: TransactionItemMapper, + private val transactionDetailsMapper: TransactionDetailsMapper, private val userProfiles: UserProfileDataSource, private val profileController: ProfileController, ) { @@ -315,6 +317,59 @@ class ActivityFeedCoordinator @Inject internal constructor( messages.map { msg -> resolveRow(msg, profiles, tokens) } } + /** + * One entry, resolved for the details screen, and kept resolved. + * + * Observed rather than read once because the screen outlives the state it opened on: cancelling + * a cash link from its own app bar lands as a feed update, and the screen has to redraw from + * that rather than from what was tapped. Null while the id isn't cached — the same thing the + * screen shows before the first read lands. + */ + fun transactionDetails(id: ID): Flow = + combine(dataSource.observeById(id), resolvers) { message, (profiles, tokens) -> + message?.let { resolveDetails(it, profiles, tokens) } + } + + /** + * [resolveRow]'s work for a single entry, with the resolve-on-miss opened up. + * + * A row badges the mint only on a person-shaped avatar, so it fetches unheld metadata only + * there. The details screen names the mint on every kind — under the amount, and again as the + * token quantity on the receipt — so an unheld mint is worth the one memoized fetch whatever + * the entry was, and a convert's destination mint is worth it too. + */ + private fun resolveDetails( + msg: ActivityFeedMessage, + profiles: Map, + tokens: Map, + ): ResolvedTransaction { + val counterparty = counterpartyOf(msg.metadata) + counterparty + ?.takeUnless { profiles.containsKey(it.hexEncodedString()) } + ?.let(::ensureProfile) + + val mint = msg.amount?.mint + val token = mint?.let { tokens[it] } + if (mint != null && token == null) ensureBadgeToken(mint) + + val destinationMint = convertOf(msg.metadata)?.toMint?.let { Mint(it.bytes) } + val destinationToken = destinationMint?.let { tokens[it] } + if (destinationMint != null && destinationToken == null) ensureBadgeToken(destinationMint) + + return ResolvedTransaction( + details = transactionDetailsMapper.map( + ActivityFeedMessageWithToken( + message = msg, + token = token, + toToken = destinationToken, + ) to profiles + ), + message = msg, + counterpartyId = counterparty, + counterparty = counterparty?.let { profiles[it.hexEncodedString()] }, + ) + } + /** * Observed profile + token caches, paired for a single [combine] against the cached pages. All * three are network-free reads that re-emit as they hydrate, so rows resolve reactively from 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 index 311a859b1..f5c83a98e 100644 --- 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 @@ -1,29 +1,22 @@ package com.flipcash.shared.transactionhistory import android.text.format.DateFormat -import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSize -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 com.flipcash.app.core.ui.ActivityAmount -import com.flipcash.app.core.ui.TokenIcon -import com.getcode.opencode.model.financial.Token -import com.flipcash.shared.common.ui.ContactAvatar -import com.flipcash.services.models.UserProfile import com.getcode.theme.CodeTheme +import com.getcode.ui.core.addIf import com.getcode.util.formatLocalized import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -34,20 +27,26 @@ import kotlin.time.Instant * A single row in the "Recent" activity list on the Wallet screen (Figma 8966:1910). * * Layout: [avatar slot] · [title + relative time (weight 1)] · [signed amount] + * + * @param onClick Opens the entry's details (Figma node 9708:105260). Null leaves the row inert — + * the wallet's preview and the full history both pass one, but a row can also be drawn purely as a + * summary. */ @Composable fun ActivityFeedRow( item: TransactionListItem, modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, ) { Row( modifier = modifier .fillMaxWidth() + .addIf(onClick != null) { Modifier.clickable(onClick = onClick!!) } .padding(vertical = CodeTheme.dimens.grid.x2), horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), verticalAlignment = Alignment.CenterVertically, ) { - AvatarSlot(item.avatar) + TransactionAvatarImage(item.avatar) Column( modifier = Modifier.weight(1f), @@ -100,98 +99,13 @@ fun ActivityFeedRow( } } -/** - * The leading slot: the avatar centred in a box wide enough for the token badge to overhang its - * bottom-right corner without pushing the title (Figma 9717:14138). Every row reserves the full - * slot, badge or not, so titles line up down the list. - * - * Sized off the static 5pt grid rather than the design's raw pixels: the avatar and badge land on it - * exactly (x8 = 40dp, x4 = 20dp), and the slot takes x10 = 50dp, which is one grid step of overhang - * on each side. That is 2dp wider than the 48dp Figma draws — the cost of keeping the row on-grid, - * and it moves the title by the same 2dp on every row rather than unevenly. - */ -@Composable -private fun AvatarSlot(avatar: TransactionAvatar, modifier: Modifier = Modifier) { - val grid = CodeTheme.dimens.staticGrid - val avatarSize = grid.x8 - Box( - modifier = modifier.requiredSize(grid.x10), - contentAlignment = Alignment.Center, - ) { - val avatarModifier = Modifier - .requiredSize(avatarSize) - .clip(CircleShape) - - when (avatar) { - is TransactionAvatar.Profile -> - ContactAvatar(userProfile = avatar.profile, modifier = avatarModifier) - is TransactionAvatar.TokenIcon -> - TokenIcon(token = avatar.token, modifier = avatarModifier) - is TransactionAvatar.SwapTokens -> - SwapAvatar(avatar, modifier = Modifier.requiredSize(avatarSize)) - is TransactionAvatar.Generic -> - ContactAvatar(userProfile = UserProfile.Empty, modifier = avatarModifier) - } - - // Ringed in the page background so the coin reads as sitting over the avatar rather than - // being part of it — the same treatment [SwapAvatar] gives its overlapping pair. - avatar.badgeToken?.let { token -> - TokenIcon( - token = token, - modifier = Modifier - .align(Alignment.BottomEnd) - .requiredSize(grid.x4) - .border(CodeTheme.dimens.thickBorder, CodeTheme.colors.background, CircleShape), - ) - } - } -} - -/** - * A convert's two tokens as overlapping coins — the destination sits over the source, ringed in the - * page background so the overlap reads as depth. Mirrors iOS's `swapAvatar` in `ActivityRow.swift`. - */ -@Composable -private fun SwapAvatar( - avatar: TransactionAvatar.SwapTokens, - modifier: Modifier = Modifier, -) { - val coin = CodeTheme.dimens.staticGrid.x5 - Box(modifier = modifier) { - TokenCoin( - token = avatar.from, - modifier = Modifier - .align(Alignment.TopStart) - .requiredSize(coin), - ) - TokenCoin( - token = avatar.to, - modifier = Modifier - .align(Alignment.BottomEnd) - .requiredSize(coin) - .border(CodeTheme.dimens.thickBorder, CodeTheme.colors.background, CircleShape), - ) - } -} - -/** One coin of a [SwapAvatar]; an unresolved side draws the shared placeholder. */ -@Composable -private fun TokenCoin(token: Token?, modifier: Modifier) { - val shaped = modifier.clip(CircleShape) - if (token != null) { - TokenIcon(token = token, modifier = shaped) - } else { - TokenIcon(image = null, modifier = shaped) - } -} - /** * 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 { +internal fun formatActivityTimestamp(instant: Instant): String { val context = LocalContext.current val is24Hour = DateFormat.is24HourFormat(context) val tz = TimeZone.currentSystemDefault() diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryScreen.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryScreen.kt index ab6940453..4e01a65e0 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryScreen.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey +import com.flipcash.app.core.AppRoute import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle @@ -62,7 +63,11 @@ fun ActivityHistoryScreen() { key = items.itemKey { it.id }, ) { index -> val item = items[index] ?: return@items - ActivityFeedRow(item = item, modifier = Modifier.fillMaxWidth()) + ActivityFeedRow( + item = item, + modifier = Modifier.fillMaxWidth(), + onClick = { navigator.push(AppRoute.Sheets.TransactionDetails(item.messageId)) }, + ) } item { diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/RecentActivitySection.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/RecentActivitySection.kt index 5f8052424..05f3147ef 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/RecentActivitySection.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/RecentActivitySection.kt @@ -31,11 +31,13 @@ import com.getcode.util.resources.R * @param modifier caller-owned modifier for the header row (padding + `clickable`); full-width is applied * internally. * @param itemPadding padding applied to each activity row (e.g. the screen's horizontal inset). + * @param onItemClick opens one entry's details; null leaves the rows inert. */ fun LazyListScope.recentActivitySection( transactions: List, modifier: Modifier = Modifier, itemPadding: PaddingValues = PaddingValues(), + onItemClick: ((TransactionListItem) -> Unit)? = null, ) { item(key = "recent_activity_header", contentType = "recent_activity_header") { RecentActivityHeader(modifier = modifier) @@ -46,6 +48,7 @@ fun LazyListScope.recentActivitySection( modifier = Modifier .fillMaxWidth() .padding(itemPadding), + onClick = onItemClick?.let { click -> { click(item) } }, ) } } diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ResolvedTransaction.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ResolvedTransaction.kt new file mode 100644 index 000000000..3ca7bba58 --- /dev/null +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ResolvedTransaction.kt @@ -0,0 +1,25 @@ +package com.flipcash.shared.transactionhistory + +import com.flipcash.app.core.feed.ActivityFeedMessage +import com.flipcash.services.models.UserProfile +import com.getcode.opencode.model.core.ID + +/** + * One activity entry, resolved for its details screen: what to draw, plus what the screen's own + * actions act on. + * + * [details] is everything the UI reads and nothing more. The rest is deliberately kept out of it: + * cancelling a cash link needs the gift-card vault, which lives in the entry's metadata, and opening + * the conversation needs the counterparty the screen just drew. Both travel with the state they were + * resolved alongside, so the screen can never act on a different entry than the one it is showing. + * + * @param counterpartyId The other party's id, taken from the metadata rather than from + * [counterparty] — a profile carries its own id only when it was fetched, and this is the id the + * conversation is opened with. + */ +data class ResolvedTransaction( + val details: TransactionDetails, + val message: ActivityFeedMessage, + val counterpartyId: ID?, + val counterparty: UserProfile?, +) diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionAvatarImage.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionAvatarImage.kt new file mode 100644 index 000000000..e98f1c9d6 --- /dev/null +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionAvatarImage.kt @@ -0,0 +1,144 @@ +package com.flipcash.shared.transactionhistory + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.shape.CircleShape +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.unit.Dp +import com.flipcash.app.core.ui.TokenIcon +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.opencode.model.financial.Token +import com.getcode.theme.CodeTheme + +/** + * Each coin of a convert's overlapping pair, as a fraction of the avatar it sits in. Fixed as a + * ratio rather than a dp so a details-screen avatar's pair scales with it; 0.625 of the 40dp row + * avatar is the 25dp the row has always drawn. + */ +private const val SwapCoinRatio = 0.625f + +/** + * A [TransactionAvatar] drawn as an image: the avatar centred in a box wide enough for the token + * badge to overhang its bottom-right corner without pushing whatever sits beside it (Figma + * 9717:14138). Every caller reserves the full slot, badge or not, so a list of them lines up. + * + * Shared between the activity row and the details screen it opens, at different sizes, so the + * screen opens on exactly the avatar that was tapped rather than a second rendering of it. + * + * The defaults are the row's: sized off the static 5pt grid rather than the design's raw pixels, + * because the avatar and badge land on it exactly (x8 = 40dp, x4 = 20dp) and the slot takes + * x10 = 50dp, one grid step of overhang on each side. That is 2dp wider than the 48dp Figma draws — + * the cost of staying on-grid, and it moves the title by the same 2dp on every row rather than + * unevenly. + * + * [iconOverride] is the token images' escape hatch for previews and screenshot tests, where the + * remote URL never resolves; it matches [com.flipcash.app.core.ui.TokenIcon]'s own parameter. + */ +@Composable +fun TransactionAvatarImage( + avatar: TransactionAvatar, + modifier: Modifier = Modifier, + size: Dp = CodeTheme.dimens.staticGrid.x8, + slotSize: Dp = CodeTheme.dimens.staticGrid.x10, + badgeSize: Dp = CodeTheme.dimens.staticGrid.x4, + iconOverride: @Composable ((Any?) -> Any?) = { it }, +) { + Box( + modifier = modifier.requiredSize(slotSize), + contentAlignment = Alignment.Center, + ) { + val avatarModifier = Modifier + .requiredSize(size) + .clip(CircleShape) + + when (avatar) { + // Their picture, falling back to their initials — NOT the anonymous silhouette. The + // silhouette answers "we don't know who this is", which is [Generic]'s job; a resolved + // profile always has a name to draw from even when it has no photo. + is TransactionAvatar.Profile -> + ContactAvatar( + image = avatar.profile.profilePicture, + displayName = avatar.profile.displayName, + // The picture belongs to this profile, so the profile is what authorizes + // re-minting its download URL once the original expires. + access = BlobAccessContext.profile(avatar.profile.userId), + modifier = avatarModifier, + ) + is TransactionAvatar.TokenIcon -> + TokenIcon(token = avatar.token, modifier = avatarModifier, iconOverride = iconOverride) + is TransactionAvatar.SwapTokens -> + SwapAvatar( + avatar = avatar, + modifier = Modifier.requiredSize(size), + coinSize = size * SwapCoinRatio, + iconOverride = iconOverride, + ) + is TransactionAvatar.Generic -> + ContactAvatar(userProfile = UserProfile.Empty, modifier = avatarModifier) + } + + // Ringed in the page background so the coin reads as sitting over the avatar rather than + // being part of it — the same treatment [SwapAvatar] gives its overlapping pair. + avatar.badgeToken?.let { token -> + TokenIcon( + token = token, + iconOverride = iconOverride, + modifier = Modifier + .align(Alignment.BottomEnd) + .requiredSize(badgeSize) + .border(CodeTheme.dimens.thickBorder, CodeTheme.colors.background, CircleShape), + ) + } + } +} + +/** + * A convert's two tokens as overlapping coins — the destination sits over the source, ringed in the + * page background so the overlap reads as depth. Mirrors iOS's `swapAvatar` in `ActivityRow.swift`. + */ +@Composable +private fun SwapAvatar( + avatar: TransactionAvatar.SwapTokens, + coinSize: Dp, + modifier: Modifier = Modifier, + iconOverride: @Composable ((Any?) -> Any?) = { it }, +) { + Box(modifier = modifier) { + TokenCoin( + token = avatar.from, + iconOverride = iconOverride, + modifier = Modifier + .align(Alignment.TopStart) + .requiredSize(coinSize), + ) + TokenCoin( + token = avatar.to, + iconOverride = iconOverride, + modifier = Modifier + .align(Alignment.BottomEnd) + .requiredSize(coinSize) + .border(CodeTheme.dimens.thickBorder, CodeTheme.colors.background, CircleShape), + ) + } +} + +/** One coin of a [SwapAvatar]; an unresolved side draws the shared placeholder. */ +@Composable +private fun TokenCoin( + token: Token?, + modifier: Modifier, + iconOverride: @Composable ((Any?) -> Any?) = { it }, +) { + val shaped = modifier.clip(CircleShape) + if (token != null) { + TokenIcon(token = token, modifier = shaped, iconOverride = iconOverride) + } else { + TokenIcon(image = null, modifier = shaped) + } +} diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetails.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetails.kt new file mode 100644 index 000000000..13d60933c --- /dev/null +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetails.kt @@ -0,0 +1,128 @@ +package com.flipcash.shared.transactionhistory + +import androidx.annotation.StringRes +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Token +import kotlin.time.Instant + +/** + * What a feed entry *was*, as the details screen says it (Figma 9708:118186). + * + * The activity feed's own row title is server-authored prose ("Tipped", "Purchased", "{0} sent + * you"), which reads fine inline but can't carry a screen. The details screen states the kind in + * the user's own voice instead, derived from the entry's [com.flipcash.app.core.feed.MessageMetadata] + * — the only structured signal the client gets. + * + * The three cash kinds are distinct because the metadata distinguishes them and a user would too. + * [GaveCash] and [ReceivedCash] are a hand-to-hand exchange — `DirectlySentCrypto` / + * `ReceivedCrypto` with neither a user id nor a phone number, so there is nobody to name. + * [SentCashLink] is `IndirectlySentCrypto`: the money sits in a gift-card vault until somebody + * opens the link, which is why it is the only kind that can still be cancelled. There is no + * received-cash-link counterpart because collecting one is indistinguishable, in the metadata, + * from taking a bill in person — both arrive as an unattributed `ReceivedCrypto`. + */ +enum class TransactionKind(@StringRes val headingRes: Int) { + Tipped(R.string.title_txnDetails_youTipped), + Received(R.string.title_txnDetails_youReceived), + Sent(R.string.title_txnDetails_youSent), + GaveCash(R.string.title_txnDetails_youGaveCash), + ReceivedCash(R.string.title_txnDetails_youReceivedCash), + SentCashLink(R.string.title_txnDetails_youSentCashLink), + Buy(R.string.title_txnDetails_buy), + Sell(R.string.title_txnDetails_sell), + Withdraw(R.string.title_txnDetails_withdraw), + Deposit(R.string.title_txnDetails_deposit), + Convert(R.string.title_txnDetails_convert), + PoolPayment(R.string.title_txnDetails_poolPayment), + Unknown(R.string.title_txnDetails_unknown), +} + +/** + * The on-chain account a withdrawal left for, or a deposit arrived from. + * + * A receipt row rather than a header line: an address is a value to check against an explorer or a + * support ticket, not part of the sentence the header reads out — and the rows are where every + * other checkable value on this screen already lives. + */ +data class TransactionAccount( + val address: String, + val direction: Direction, +) { + enum class Direction(@StringRes val labelRes: Int) { + To(R.string.label_txnDetails_to), + From(R.string.label_txnDetails_from), + } + + /** Nobody reads the middle of an address; the ends are what someone actually compares. */ + val shortAddress: String get() = truncate(address) + + companion object { + fun truncate(address: String, edge: Int = 4): String = + if (address.length <= edge * 2 + 1) address + else "${address.take(edge)}…${address.takeLast(edge)}" + } +} + +/** The entry's settlement state, as the Status row phrases it. */ +enum class TransactionStatus(@StringRes val labelRes: Int) { + Pending(R.string.label_txnDetails_status_pending), + Completed(R.string.label_txnDetails_status_completed), + Failed(R.string.label_txnDetails_status_failed), + Unknown(R.string.label_txnDetails_status_unknown), +} + +/** + * Everything the details screen draws, resolved. + * + * Every numeric field arrives pre-computed ([exchangeRate], [tokenAmount]) rather than as the + * amount plus the token metadata needed to derive it, so the screen never runs [Fiat] or + * [com.flipcash.libs.currency.math.Estimator] math mid-composition — and a preview or a test can + * state a row's value directly instead of reconstructing the mint that would produce it. + * + * @param id The entry's id, base58-encoded — what the copy control puts on the clipboard. + * @param avatar The row's avatar, reused verbatim so the screen opens on the thing that was tapped. + * @param heading What the screen is titled, when the entry has something better to say than its + * [kind] — which is the person-to-person case: a tip, a send and a receive are all headed by the + * counterparty's display name, and the +/- on the amount is what states the direction. Null falls + * back to [kind]'s own heading, which is what an unresolved counterparty gets: "You tipped" beats a + * blank line. + * @param subtitle The other side of the movement, under the heading: "In Person", the token a buy + * was paid with, a convert's destination mint, and so on — see [TransactionSubtitles], which + * resolves it per kind. Null wherever the header already says everything: a person entry, whose + * name is the [heading]; a cash link, whose own heading names it; a withdrawal or deposit, whose + * other side is an address and belongs in [account] where it can be read digit by digit. + * @param signedAmountPrefix "-", "+", or null — the same direction marker the activity row puts on + * its amount, so the two read the same movement the same way. + * @param amount The entry's amount in the user's local currency, or null for a non-financial entry. + * @param token The mint the entry moved in — the sub-line under the amount, and the badge on the + * avatar. Null until the mint's metadata resolves. + * @param toToken A convert's destination mint; null for everything else. + * @param account The account a withdrawal left for or a deposit arrived from; null for every kind + * that moved between people or mints rather than to an address. + * @param received A convert's destination amount. Null while the swap is still pending. + * @param fee What the movement cost. Converts only. + * @param canCancel Whether the movement can still be pulled back — an open cash link, and nothing + * else. Drawn as the app bar's end action. + * @param canViewInChat Whether the counterparty's conversation can be opened from here. + */ +data class TransactionDetails( + val id: String, + val kind: TransactionKind, + val avatar: TransactionAvatar, + val heading: String? = null, + val subtitle: String?, + val signedAmountPrefix: String?, + val amount: Fiat?, + val timestamp: Instant, + val token: Token?, + val toToken: Token? = null, + val account: TransactionAccount? = null, + val status: TransactionStatus, + val currencyCode: String?, + val exchangeRate: Double?, + val tokenAmount: String?, + val fee: Fiat? = null, + val received: Fiat? = null, + val canCancel: Boolean = false, + val canViewInChat: Boolean = false, +) diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsContent.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsContent.kt new file mode 100644 index 000000000..9afafdbbb --- /dev/null +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsContent.kt @@ -0,0 +1,339 @@ +package com.flipcash.shared.transactionhistory + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import com.flipcash.app.core.ui.FlagWithFiat +import com.flipcash.app.core.ui.ReceiptLineItem +import com.flipcash.app.core.ui.TokenIconWithName +import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 +import com.getcode.theme.extraSmall +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.core.unboundedClickable +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.util.format +import java.util.Locale + +/** + * The transaction details screen (Figma 9708:105260) — what opens when an activity row, a feed + * item, or a recents entry is tapped. + * + * Stateless: everything it draws is resolved in [TransactionDetails], so it renders identically + * from live data and from a screenshot test. + * + * The header restates the entry the way the user would: the row's own avatar, then who or what it + * was ("Sally The Streamer", "Withdraw"), then its other side where the heading hasn't already said + * it ("In Person", "with Dollars" — see [TransactionSubtitles]), then how much and in which + * direction, then when and in which token. The nav title stays the literal "Details", so the entry + * has to name itself in the header rather than the bar. + * + * Cancelling is the bar's end action rather than a control at the foot of the scroll: it applies to + * the whole entry, not to anything in the receipt, and putting it below a variable-length card + * meant it landed in a different place on every kind — and sometimes below the fold. + * + * [iconOverride] threads through to the token images for previews and screenshot tests, where the + * remote URL never resolves. + */ +@Composable +fun TransactionDetailsContent( + details: TransactionDetails, + modifier: Modifier = Modifier, + onBack: () -> Unit = { }, + onCopyId: () -> Unit = { }, + onViewInChat: () -> Unit = { }, + onCancel: () -> Unit = { }, + iconOverride: @Composable ((Any?) -> Any?) = { it }, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(CodeTheme.colors.background), + ) { + AppBarWithTitle( + title = stringResource(R.string.title_txnDetails), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = onBack, + endContent = { + if (details.canCancel) { + Text( + modifier = Modifier + .unboundedClickable(onClick = onCancel) + .padding(horizontal = CodeTheme.dimens.grid.x2), + text = stringResource(R.string.action_txnDetails_cancel), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.error, + ) + } + }, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x6), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Header(details = details, iconOverride = iconOverride) + + DetailCard(details = details) + + IdCard(id = details.id, onCopyId = onCopyId) + + if (details.canViewInChat) { + CodeButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.action_txnDetails_viewInChat), + buttonState = ButtonState.Filled10, + onClick = onViewInChat, + ) + } + } + } +} + +/** + * Two stacked blocks, as Figma 9708:117414 draws them: who or what it was, then how much and when. + * Lines sit one grid step apart within a block and two between them, which is why this is a pair of + * nested columns rather than one evenly-spaced stack — the wider gap either side of the amount is + * what separates the two facts. + * + * Image sizes come off [com.getcode.theme.Dimensions.staticGrid] so the avatar and the icons keep + * their size across width classes; the gaps around them come off the responsive `grid`, like the + * rest of the screen's spacing. + */ +@Composable +private fun Header( + details: TransactionDetails, + modifier: Modifier = Modifier, + iconOverride: @Composable ((Any?) -> Any?) = { it }, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(top = CodeTheme.dimens.grid.x3), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + TransactionAvatarImage( + avatar = details.avatar, + size = CodeTheme.dimens.staticGrid.x16, + slotSize = CodeTheme.dimens.staticGrid.x18, + badgeSize = CodeTheme.dimens.staticGrid.x6, + iconOverride = iconOverride, + ) + + // A person-to-person entry is titled by the person; everything else by what it was. + Text( + text = details.heading?.takeIf { it.isNotBlank() } + ?: stringResource(details.kind.headingRes), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + + // The other side of the movement, for the kinds whose heading doesn't already carry + // it — see [TransactionSubtitles]. + details.subtitle?.takeIf { it.isNotBlank() }?.let { subtitle -> + Text( + text = subtitle, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + details.amount?.let { amount -> + FlagWithFiat( + fiat = amount, + extraPrefix = details.signedAmountPrefix?.ifBlank { null }, + iconSize = CodeTheme.dimens.staticGrid.x5, + spacing = CodeTheme.dimens.grid.x1, + textStyle = CodeTheme.typography.displaySmall, + ) + } + + // When, and in which token — the mint is what "$20.00" alone never says. + Row( + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = formatActivityTimestamp(details.timestamp), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + details.token?.let { token -> + // A drawn dot, not a "•" glyph: the glyph's size and its offset from the + // baseline are the font's to decide, and the design wants a small circle + // centred on the line. + Box( + modifier = Modifier + .size(CodeTheme.dimens.staticGrid.x1) + .background(CodeTheme.colors.textSecondary, CircleShape), + ) + TokenIconWithName( + token = token, + imageSize = CodeTheme.dimens.staticGrid.x3, + iconOverride = iconOverride, + textStyle = CodeTheme.typography.textSmall, + textColor = CodeTheme.colors.textSecondary, + spacing = CodeTheme.dimens.grid.x1, + ) + } + } + } + } +} + +/** + * The receipt rows. A non-financial entry (unknown metadata, no amount) has no currency, rate or + * token quantity to state, so the card collapses to when-and-what-state rather than showing empty + * rows. + */ +@Composable +private fun DetailCard(details: TransactionDetails, modifier: Modifier = Modifier) { + DetailsCard(modifier = modifier) { + details.currencyCode?.let { + ReceiptLineItem( + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.label_txnDetails_currency), + amount = it, + ) + } + details.exchangeRate?.let { + ReceiptLineItem( + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.label_txnDetails_exchangeRate), + amount = String.format(Locale.US, "%.6f", it), + ) + } + ReceiptLineItem( + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.label_txnDetails_date), + amount = details.timestamp.format("M/d/yyyy h:mm a"), + ) + details.tokenAmount?.let { + ReceiptLineItem( + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.label_txnDetails_tokens), + amount = it, + ) + } + // Where it went, or where it came from. Shortened to its two ends, like the id above it. + details.account?.let { account -> + ReceiptLineItem( + modifier = Modifier.fillMaxWidth(), + label = stringResource(account.direction.labelRes), + amount = account.shortAddress, + ) + } + details.fee?.let { + ReceiptLineItem( + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.label_txnDetails_fee), + amount = it.formatted(), + ) + } + details.received?.let { + ReceiptLineItem( + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.label_txnDetails_received), + amount = it.formatted(), + ) + } + ReceiptLineItem( + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.label_txnDetails_status), + amount = stringResource(details.status.labelRes), + ) + } +} + +/** + * The entry's id, in its own card so the copy control has an obvious target. The id is long and + * meaningless to read, so it middle-ellipsizes — both ends stay legible, which is what someone + * eyeballing it against a support ticket actually compares. + */ +@Composable +private fun IdCard(id: String, onCopyId: () -> Unit, modifier: Modifier = Modifier) { + DetailsCard(modifier = modifier.unboundedClickable(onClick = onCopyId)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.label_txnDetails_id), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + Text( + modifier = Modifier.weight(1f), + text = id, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + textAlign = TextAlign.End, + ) + Icon( + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), + painter = painterResource(R.drawable.ic_copy), + contentDescription = stringResource(R.string.action_txnDetails_copyId), + tint = CodeTheme.colors.textSecondary, + ) + } + } +} + +/** + * The panel both cards sit in (Figma 9708:117417) — a tint over the background and a small radius, + * no outline: the cards are the only things on the background, so a border would draw a boundary + * the fill already draws. + */ +@Composable +private fun DetailsCard( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(White05, CodeTheme.shapes.extraSmall) + .padding(CodeTheme.dimens.grid.x3), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + content = content, + ) +} 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 index 6d7df683c..d58bde92f 100644 --- 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 @@ -1,6 +1,7 @@ package com.flipcash.shared.transactionhistory import com.flipcash.services.models.UserProfile +import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token @@ -46,6 +47,9 @@ sealed interface TransactionAvatar { data class TransactionListItem( val id: String, // stable paging key (message.id hex-encoded) + // The raw id, for opening the entry's details. Carried alongside [id] rather than decoded back + // out of it: [id] exists to be a paging key, and hex has no decoder in the app. + val messageId: ID, val title: String, // server-provided message.text val timestamp: Instant, val avatar: TransactionAvatar, diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionSubtitles.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionSubtitles.kt new file mode 100644 index 000000000..31649136c --- /dev/null +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionSubtitles.kt @@ -0,0 +1,49 @@ +package com.flipcash.shared.transactionhistory + +import com.getcode.util.resources.ResourceHelper + +/** + * The one line under the details heading — the other side of the movement, for the kinds whose + * heading doesn't already carry it. + * + * A person-to-person entry has no line here: the person *is* the heading + * ([TransactionDetails.heading]), and a name repeated under itself says nothing. The same goes for + * a cash link, which its heading already names, and for a withdrawal or deposit, whose other side + * is an account address — a value to check rather than a phrase to read, so it goes to + * [TransactionAccount] and the card. + * + * What is left is the kinds whose other side isn't a face and isn't in the heading: a buy was paid + * *with* something, a sell came back *for* something, a convert has a destination mint, a pool + * payment has a pool. + * + * Collected here rather than inline in the mapper so the screen's fixtures and the mapper agree on + * what that line says for each [TransactionKind]. + */ +object TransactionSubtitles { + + /** + * [TransactionKind.GaveCash] / [TransactionKind.ReceivedCash] — a bill handed over face to + * face. The metadata carries neither a user id nor a phone number because the hand-off never + * exchanges identities, so "who" is genuinely unanswerable; how it moved is the useful fact. + */ + fun inPerson(resources: ResourceHelper): String = + resources.getString(R.string.label_txnDetails_inPerson) + + /** [TransactionKind.Buy] — the mint that was spent, which the amount alone never says. */ + fun paidWith(resources: ResourceHelper, tokenName: String): String = + resources.getString(R.string.label_txnDetails_paidWith, tokenName) + + /** [TransactionKind.Sell] — the mint that came back. */ + fun soldFor(resources: ResourceHelper, tokenName: String): String = + resources.getString(R.string.label_txnDetails_soldFor, tokenName) + + /** + * [TransactionKind.Convert] — both mints, in the same "from → to" shape the activity row's + * convert title already uses, so the row and the screen it opens read alike. + */ + fun converted(resources: ResourceHelper, from: String, to: String): String = + resources.getString(R.string.title_activity_convert, from, to) + + /** [TransactionKind.PoolPayment] — the pool that was paid. */ + fun pool(name: String): String? = name.takeIf { it.isNotBlank() } +} diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt new file mode 100644 index 000000000..989407b87 --- /dev/null +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt @@ -0,0 +1,182 @@ +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.MessageState +import com.flipcash.app.core.feed.SwapState +import com.flipcash.services.models.UserProfile +import com.flipcash.shared.transactionhistory.TransactionAvatar +import com.flipcash.shared.transactionhistory.TransactionDetails +import com.flipcash.shared.transactionhistory.TransactionKind +import com.flipcash.shared.transactionhistory.TransactionStatus +import com.flipcash.shared.transactionhistory.TransactionSubtitles +import com.flipcash.shared.transactionhistory.convertOf +import com.getcode.opencode.mapper.Mapper +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.Mint +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.base58 +import com.getcode.utils.hexEncodedString +import javax.inject.Inject + +/** + * Maps a feed entry to the details screen's resolved state — the same entry the activity row draws, + * restated as a screen. + * + * Shares [TransactionItemMapper]'s reading of the metadata (which counterparty, which avatar, which + * direction) so a row and the screen it opens can never disagree about what the entry was; what it + * adds is everything a row has no space for: the kind stated in the user's own voice, the receipt + * values, and the actions. + * + * Pure and synchronous, like the row mapper: profiles and token metadata arrive through the caches + * the coordinator observes, so an unresolved counterparty or mint simply fills in when it lands. + */ +internal class TransactionDetailsMapper @Inject constructor( + private val resources: ResourceHelper, +) : Mapper>, TransactionDetails> { + + override fun map(from: Pair>): TransactionDetails { + 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 convert = convertOf(meta) + val kind = kindOf(meta, msg.text) + + return TransactionDetails( + // Base58, not the row's hex: this is the value someone pastes into a support ticket or + // an explorer, and base58 is how the rest of the app writes an id a person will read. + id = msg.id.base58, + kind = kind, + avatar = avatarOf(counterparty, convert != null, meta, token, source.toToken), + // A person entry is titled by the person; everything else falls back to the kind's own + // heading, which is also what an unresolved counterparty gets. + heading = counterparty?.displayName?.takeIf { it.isNotBlank() }, + subtitle = subtitleOf(kind, token, source.toToken), + signedAmountPrefix = when { + meta == null -> null + meta.isOutgoing -> "-" + else -> "+" + }, + amount = msg.amount?.nativeAmount, + timestamp = msg.timestamp, + token = token, + toToken = source.toToken, + // The feed carries no destination address: `WithdrewCrypto` and `DepositedCrypto` say + // only that money left or arrived, so there is nothing to put in the To/From row until + // the notification metadata carries the account. + account = null, + status = statusOf(msg.state, convert?.swapState), + currencyCode = msg.amount?.nativeAmount?.currencyCode?.name, + exchangeRate = msg.amount?.rate?.fx, + tokenAmount = tokenAmountOf(msg.amount?.underlyingTokenAmount, token), + fee = convert?.fee, + // A pending swap has no destination amount yet, which is exactly when the row is left + // out rather than shown as zero. + received = convert?.toAmount?.nativeAmount, + canCancel = (meta as? MessageMetadata.IndirectlySentCrypto)?.canCancel == true, + // Opening the conversation needs somebody to open it with, and the profile is what the + // chat header renders from on the first frame (see `ChatIdentifier.ByUser`). + canViewInChat = counterparty != null, + ) + } + + /** + * The other side of the movement, for the kinds whose heading doesn't already carry it. Null + * wherever the header already says everything, and wherever the metadata can't answer it: a + * legacy buy/sell records only the mint that moved, and a pool payment carries a pool id rather + * than a name. + */ + private fun subtitleOf(kind: TransactionKind, token: Token?, toToken: Token?): String? = + when (kind) { + TransactionKind.GaveCash, TransactionKind.ReceivedCash -> + TransactionSubtitles.inPerson(resources) + TransactionKind.Convert -> { + val from = token?.name?.takeIf { it.isNotBlank() } + val to = toToken?.name?.takeIf { it.isNotBlank() } + if (from != null && to != null) { + TransactionSubtitles.converted(resources, from, to) + } else { + null + } + } + else -> null + } +} + +/** + * What the entry *was*, from the only structured signal the client gets. + * + * The two hand-to-hand kinds are the send/receive pair with neither a user id nor a phone number — + * a bill hand-off never exchanges identities, so there is genuinely nobody to name (see + * [isUnidentifiedBill]). [text] separates a tip from a plain send, which the metadata doesn't: the + * server encodes it in the verb, and [isTipVerb] is where that is read. + */ +private fun kindOf(meta: MessageMetadata?, text: String): TransactionKind = when (meta) { + is MessageMetadata.DirectlySentCrypto -> when { + isUnidentifiedBill(meta) -> TransactionKind.GaveCash + text.isTipVerb() -> TransactionKind.Tipped + else -> TransactionKind.Sent + } + is MessageMetadata.ReceivedCrypto -> + if (isUnidentifiedBill(meta)) TransactionKind.ReceivedCash else TransactionKind.Received + is MessageMetadata.IndirectlySentCrypto -> TransactionKind.SentCashLink + is MessageMetadata.WithdrewCrypto -> TransactionKind.Withdraw + MessageMetadata.DepositedCrypto -> TransactionKind.Deposit + MessageMetadata.BoughtToken -> TransactionKind.Buy + MessageMetadata.SoldToken -> TransactionKind.Sell + is MessageMetadata.SwappedCrypto -> TransactionKind.Convert + is MessageMetadata.PaidCrypto -> TransactionKind.PoolPayment + MessageMetadata.Unknown, null -> TransactionKind.Unknown +} + +/** The row's avatar, resolved exactly as [TransactionItemMapper] resolves it. */ +private fun avatarOf( + counterparty: UserProfile?, + isConvert: Boolean, + meta: MessageMetadata?, + token: Token?, + toToken: Token?, +): TransactionAvatar = when { + counterparty != null -> TransactionAvatar.Profile(counterparty, badgeToken = token) + isConvert -> TransactionAvatar.SwapTokens(from = token, to = toToken) + (hasNoCounterparty(meta) || isUnidentifiedBill(meta)) && token != null -> + TransactionAvatar.TokenIcon(token) + else -> TransactionAvatar.Generic(badgeToken = token) +} + +/** + * The settlement state the Status row reads out. + * + * A convert is settled by its swap, not by the notification: the entry itself completes as soon as + * the source side is debited, so a failed swap would otherwise read "Completed". + */ +private fun statusOf(state: MessageState, swapState: SwapState?): TransactionStatus = when { + swapState == SwapState.FAILED -> TransactionStatus.Failed + swapState == SwapState.PENDING -> TransactionStatus.Pending + state == MessageState.PENDING -> TransactionStatus.Pending + state == MessageState.COMPLETED -> TransactionStatus.Completed + else -> TransactionStatus.Unknown +} + +/** + * How many tokens the entry moved, formatted to the mint's own precision. + * + * The reserve is one-to-one with its USD value, so it needs no curve. Every other mint is priced by + * the bonding curve, and [Fiat.estimatedTokenAmountIn] prices it against the mint's *current* + * supply — so this is the quantity that value is worth now, not what it bought at the time. Stating + * it is still better than leaving the row blank, since the value is the same value the header shows. + */ +private fun tokenAmountOf(underlying: Fiat?, token: Token?): String? { + underlying ?: return null + token ?: return null + val quantity = if (token.address == Mint.usdf) { + underlying + } else { + Fiat.tokenBalance(underlying.quarks, token) + } + return quantity.estimatedTokenAmountIn(token) +} 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 index 2113e7989..8009e0d32 100644 --- 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 @@ -55,6 +55,7 @@ internal class TransactionItemMapper @Inject constructor( // 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(), + messageId = msg.id, title = convertTitle(resources, source) ?: resolveTitle(resources, meta, msg.text, msg.textSubstitutions, counterparty, profiles), timestamp = msg.timestamp, @@ -146,9 +147,9 @@ private fun resolveTitle( * gets. Matching it is safe while `localized_text` is English-only; a localized feed would need the * distinction promoted into the notification metadata. */ -private fun String.isTipVerb(): Boolean = trim().startsWith("tip", ignoreCase = true) +internal fun String.isTipVerb(): Boolean = trim().startsWith("tip", ignoreCase = true) -private fun userIdOf(meta: MessageMetadata?): ID? = when (meta) { +internal fun userIdOf(meta: MessageMetadata?): ID? = when (meta) { is MessageMetadata.DirectlySentCrypto -> meta.userId is MessageMetadata.ReceivedCrypto -> meta.userId else -> null @@ -165,13 +166,13 @@ private fun userIdOf(meta: MessageMetadata?): ID? = when (meta) { * Deliberately narrow: a peer payment whose profile simply hasn't landed yet *does* carry an * identifier, so it stays generic and swaps in the real avatar when the profile arrives. */ -private fun isUnidentifiedBill(meta: MessageMetadata?): Boolean = when (meta) { +internal fun isUnidentifiedBill(meta: MessageMetadata?): Boolean = when (meta) { is MessageMetadata.DirectlySentCrypto -> meta.userId == null && meta.phoneNumber == null is MessageMetadata.ReceivedCrypto -> meta.userId == null && meta.phoneNumber == null else -> false } -private fun hasNoCounterparty(meta: MessageMetadata?): Boolean = when (meta) { +internal fun hasNoCounterparty(meta: MessageMetadata?): Boolean = when (meta) { MessageMetadata.DepositedCrypto, is MessageMetadata.WithdrewCrypto, MessageMetadata.BoughtToken, diff --git a/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml b/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml index 2c3c42e80..f827a5048 100644 --- a/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml +++ b/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml @@ -10,4 +10,50 @@ %1$s → %2$s -%1$s Fee + + + Details + + + You tipped + You received + You sent + You gave cash + You received cash + You sent a cash link + Buy + Sell + Withdraw + Deposit + Convert + Pool payment + Transaction + + + + In Person + with %1$s + for %1$s + + To + From + Currency + Exchange Rate + Date + Tokens + Fee + Received + Status + ID + + + Pending + Completed + Failed + Unknown + + + View in Chat + Copy transaction ID + Cancel diff --git a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt new file mode 100644 index 000000000..c4ab7276f --- /dev/null +++ b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt @@ -0,0 +1,225 @@ +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.SwapState +import com.flipcash.app.core.feed.SwappedCryptoMetadata +import com.flipcash.services.models.UserProfile +import com.flipcash.shared.transactionhistory.internal.TransactionDetailsMapper +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.util.resources.FakeResourceHelper +import com.getcode.utils.base58 +import com.getcode.utils.hexEncodedString +import kotlin.time.Instant +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The details mapper's readings of the metadata — which kind, which status, which actions. + * + * Deliberately mirrors [TransactionItemMapperTest]'s fixtures: the two mappers read the same entry, + * and a row and the screen it opens disagreeing about what the entry was is the failure worth + * catching. + */ +class TransactionDetailsMapperTest { + + private val resources = FakeResourceHelper() + .stub(R.string.title_activity_convert, "%1\$s → %2\$s") + .stub(R.string.label_txnDetails_inPerson, "In Person") + private val mapper = TransactionDetailsMapper(resources) + + 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 val vault = PublicKey.fromBase58("11111111111111111111111111111111") + + private fun feedMessage( + metadata: MessageMetadata?, + text: String = "Sent", + state: MessageState = MessageState.COMPLETED, + ): ActivityFeedMessage = ActivityFeedMessage( + id = listOf(0x01, 0x02, 0x03).map { it.toByte() }, + text = text, + amount = LocalFiat( + usdf = Fiat(20.0, CurrencyCode.USD), + nativeAmount = Fiat(20.0, CurrencyCode.USD), + ), + timestamp = Instant.fromEpochSeconds(1700000000L), + state = state, + metadata = metadata, + ) + + private fun map( + metadata: MessageMetadata?, + text: String = "Sent", + state: MessageState = MessageState.COMPLETED, + token: Token? = null, + toToken: Token? = null, + ): TransactionDetails = mapper.map( + ActivityFeedMessageWithToken( + feedMessage(metadata, text, state), + token = token, + toToken = toToken, + ) to cached + ) + + 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 `the verb separates a tip from a plain send`() { + val meta = MessageMetadata.DirectlySentCrypto(userId = knownUserId) + + assertEquals(TransactionKind.Tipped, map(meta, text = "Tipped").kind) + assertEquals(TransactionKind.Sent, map(meta, text = "Sent").kind) + } + + @Test + fun `a send with nobody named is a bill handed over, not a send`() { + val details = map(MessageMetadata.DirectlySentCrypto()) + + assertEquals(TransactionKind.GaveCash, details.kind) + // Nobody to head the screen with, so the kind's own heading stands. + assertNull(details.heading) + assertEquals("In Person", details.subtitle) + } + + @Test + fun `a receive with nobody named is cash taken in person`() { + assertEquals(TransactionKind.ReceivedCash, map(MessageMetadata.ReceivedCrypto()).kind) + } + + @Test + fun `a cached counterparty heads the screen and opens the conversation`() { + val details = map(MessageMetadata.ReceivedCrypto(userId = knownUserId)) + + assertEquals(TransactionKind.Received, details.kind) + assertEquals("Sally The Streamer", details.heading) + assertEquals(TransactionAvatar.Profile(knownProfile), details.avatar) + assertTrue(details.canViewInChat) + assertEquals("+", details.signedAmountPrefix) + } + + @Test + fun `an unresolved counterparty leaves the heading and the chat action to the kind`() { + val details = mapper.map( + ActivityFeedMessageWithToken( + feedMessage(MessageMetadata.ReceivedCrypto(userId = knownUserId)), + token = null, + ) to emptyMap() + ) + + assertNull(details.heading) + assertFalse(details.canViewInChat) + } + + @Test + fun `only an open cash link can be cancelled`() { + val open = MessageMetadata.IndirectlySentCrypto(creator = vault, canCancel = true) + val claimed = MessageMetadata.IndirectlySentCrypto(creator = vault, canCancel = false) + + assertEquals(TransactionKind.SentCashLink, map(open).kind) + assertTrue(map(open).canCancel) + assertFalse(map(claimed).canCancel) + assertFalse(map(MessageMetadata.DirectlySentCrypto(userId = knownUserId)).canCancel) + } + + @Test + fun `a convert names both mints and draws both sides`() { + val from = token(Mint.usdf, name = "Dollars", symbol = "USDF") + val to = token(Mint(PublicKey.fromBase58("So11111111111111111111111111111111111111112").bytes), name = "Jeffy", symbol = "JEFFY") + val meta = MessageMetadata.SwappedCrypto( + SwappedCryptoMetadata( + from = LocalFiat(usdf = Fiat(20.0, CurrencyCode.USD), nativeAmount = Fiat(20.0, CurrencyCode.USD)), + toMint = to.address, + toAmount = LocalFiat(usdf = Fiat(19.0, CurrencyCode.USD), nativeAmount = Fiat(19.0, CurrencyCode.USD)), + fee = Fiat(1.0, CurrencyCode.USD), + swapState = SwapState.SUCCEEDED, + ) + ) + + val details = map(meta, token = from, toToken = to) + + assertEquals(TransactionKind.Convert, details.kind) + assertEquals("Dollars → Jeffy", details.subtitle) + assertEquals(TransactionAvatar.SwapTokens(from = from, to = to), details.avatar) + assertEquals(Fiat(1.0, CurrencyCode.USD), details.fee) + assertEquals(Fiat(19.0, CurrencyCode.USD), details.received) + // A swap debits the source mint. + assertEquals("-", details.signedAmountPrefix) + } + + @Test + fun `a failed swap fails the entry, whatever the notification says`() { + val to = token(Mint(PublicKey.fromBase58("So11111111111111111111111111111111111111112").bytes), name = "Jeffy", symbol = "JEFFY") + val meta = MessageMetadata.SwappedCrypto( + SwappedCryptoMetadata( + from = LocalFiat(usdf = Fiat(20.0, CurrencyCode.USD), nativeAmount = Fiat(20.0, CurrencyCode.USD)), + toMint = to.address, + toAmount = null, + fee = Fiat(0.0, CurrencyCode.USD), + swapState = SwapState.FAILED, + ) + ) + + // The entry itself completes as soon as the source side is debited. + val details = map(meta, state = MessageState.COMPLETED) + + assertEquals(TransactionStatus.Failed, details.status) + assertNull(details.received) + } + + @Test + fun `a pending entry reads as pending`() { + val details = map(MessageMetadata.DepositedCrypto, state = MessageState.PENDING) + + assertEquals(TransactionKind.Deposit, details.kind) + assertEquals(TransactionStatus.Pending, details.status) + } + + @Test + fun `the copied id is base58, not the row's paging key`() { + val msg = feedMessage(MessageMetadata.DepositedCrypto) + val details = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to cached) + + assertEquals(msg.id.base58, details.id) + } + + @Test + fun `a withdrawal has no account to show`() { + // Neither the message metadata nor the notification carries a destination address, so the + // To/From row has nothing to render until one does. + assertNull(map(MessageMetadata.WithdrewCrypto()).account) + } +} diff --git a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsSamples.kt b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsSamples.kt new file mode 100644 index 000000000..27b97d73a --- /dev/null +++ b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsSamples.kt @@ -0,0 +1,220 @@ +package com.flipcash.shared.transactionhistory + +import com.flipcash.services.models.UserProfile +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.MintMetadata +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.usdf +import com.getcode.solana.keys.Mint +import kotlin.time.Instant + +/** + * Fixtures for the details-screen renders. Held in the test source set: they exist to pin the + * states down visually, not to ship. + * + * Subtitles are spelled out here rather than resolved through [TransactionSubtitles], which needs a + * `ResourceHelper` the fixtures have no reason to stand up; they must stay in step with + * `strings.xml`, which is what these renders are for. + */ +internal object TransactionDetailsSamples { + + val At: Instant = Instant.parse("2026-08-29T18:42:00Z") + + /** A destination account, as the receipt row shortens it. */ + private const val Account = "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU" + + private fun mint(byte: Byte) = Mint(List(32) { byte }) + + /** + * Token art is served per-mint by the backend, so the fixtures name a file the screenshot + * test's image loader answers from `src/test/resources/tokens/` — the tokens' own icons, + * through the same [com.flipcash.app.core.ui.TokenIcon] path the app uses. + */ + private fun token(name: String, symbol: String, address: Mint, image: String): Token = MintMetadata( + address = address, + decimals = 6, + name = name, + symbol = symbol, + createdAt = At, + description = "", + imageUrl = "https://example.invalid/tokens/$image", + vmMetadata = MintMetadata.usdf.vmMetadata, + launchpadMetadata = null, + billCustomizations = null, + socialLinks = emptyList(), + holderMetrics = HolderMetrics.None, + ) + + val Jeffy: Token = token("Jeffy", "JEFFY", mint(1), "jeffy.png") + + /** The reserve — the real mint, since the screen keys off it the way the rest of the app does. */ + val Dollars: Token = token("Dollars", "USDF", Mint.usdf, "dollars.webp") + + /** No profile picture, so the avatar draws her initials — the app's real no-photo state. */ + val Sally = UserProfile.Empty.copy(displayName = "Sally The Streamer") + + private fun usd(amount: Double) = Fiat(amount, CurrencyCode.USD) + + private fun base( + kind: TransactionKind, + avatar: TransactionAvatar, + amount: Fiat?, + token: Token?, + tokenAmount: String?, + subtitle: String?, + prefix: String?, + heading: String? = null, + ) = TransactionDetails( + id = "5KJp7z2Fh9qVYc3mXbNs1LtR8dGw4eA6uQnW", + kind = kind, + avatar = avatar, + heading = heading, + subtitle = subtitle, + signedAmountPrefix = prefix, + amount = amount, + timestamp = At, + token = token, + status = TransactionStatus.Completed, + currencyCode = amount?.currencyCode?.name, + exchangeRate = amount?.let { 1.0 }, + tokenAmount = tokenAmount, + ) + + /** The person is the heading; the sign on the amount is what says which way it went. */ + private fun person(kind: TransactionKind, amount: Double, prefix: String) = base( + kind = kind, + avatar = TransactionAvatar.Profile(Sally, badgeToken = Jeffy), + amount = usd(amount), + token = Jeffy, + tokenAmount = "1,204.905", + heading = Sally.displayName, + subtitle = null, + prefix = prefix, + ).copy(canViewInChat = true) + + val Tipped = person(TransactionKind.Tipped, 20.0, "-") + val ReceivedFromPerson = person(TransactionKind.Received, 5.0, "+") + val SentToPerson = person(TransactionKind.Sent, 12.50, "-") + + /** A bill handed over face to face — nobody to name, so the line says how it moved. */ + val GaveCash = base( + kind = TransactionKind.GaveCash, + avatar = TransactionAvatar.TokenIcon(Jeffy), + amount = usd(3.00), + token = Jeffy, + tokenAmount = "180.735", + subtitle = "In Person", + prefix = "-", + ) + + val ReceivedCash = base( + kind = TransactionKind.ReceivedCash, + avatar = TransactionAvatar.TokenIcon(Dollars), + amount = usd(1.00), + token = Dollars, + tokenAmount = "1.000000", + subtitle = "In Person", + prefix = "+", + ) + + /** A link somebody has already opened. Its heading names it, so there is no line under it. */ + val SentCashLink = base( + kind = TransactionKind.SentCashLink, + avatar = TransactionAvatar.TokenIcon(Jeffy), + amount = usd(7.50), + token = Jeffy, + tokenAmount = "451.838", + subtitle = null, + prefix = "-", + ) + + val Buy = base( + kind = TransactionKind.Buy, + avatar = TransactionAvatar.TokenIcon(Jeffy), + amount = usd(50.00), + token = Jeffy, + tokenAmount = "3,012.264", + subtitle = "with Dollars", + prefix = "+", + ) + + val Sell = base( + kind = TransactionKind.Sell, + avatar = TransactionAvatar.TokenIcon(Jeffy), + amount = usd(18.20), + token = Jeffy, + tokenAmount = "1,096.463", + subtitle = "for Dollars", + prefix = "-", + ) + + val Withdraw = base( + kind = TransactionKind.Withdraw, + avatar = TransactionAvatar.TokenIcon(Dollars), + amount = usd(120.00), + token = Dollars, + tokenAmount = "120.000000", + subtitle = null, + prefix = "-", + ).copy(account = TransactionAccount(Account, TransactionAccount.Direction.To)) + + val Deposit = base( + kind = TransactionKind.Deposit, + avatar = TransactionAvatar.TokenIcon(Dollars), + amount = usd(250.00), + token = Dollars, + tokenAmount = "250.000000", + subtitle = null, + prefix = "+", + ).copy(account = TransactionAccount(Account, TransactionAccount.Direction.From)) + + val Convert = base( + kind = TransactionKind.Convert, + avatar = TransactionAvatar.SwapTokens(from = Dollars, to = Jeffy), + amount = usd(40.00), + token = Dollars, + tokenAmount = "40.000000", + subtitle = "Dollars → Jeffy", + prefix = "-", + ).copy( + toToken = Jeffy, + fee = usd(0.40), + received = usd(39.60), + ) + + /** No metadata at all: no amount, so no currency, rate or token quantity to state. */ + val Unknown = base( + kind = TransactionKind.Unknown, + avatar = TransactionAvatar.Generic(), + amount = null, + token = null, + tokenAmount = null, + subtitle = null, + prefix = null, + ).copy(status = TransactionStatus.Unknown) + + /** A link nobody has opened yet, so it can still be pulled back from the app bar. */ + val OpenCashLink = SentCashLink.copy( + status = TransactionStatus.Pending, + canCancel = true, + ) + + /** Every state, in the order the renders are laid out. */ + val All: List> = listOf( + "01_you_tipped" to Tipped, + "02_you_received" to ReceivedFromPerson, + "03_you_sent" to SentToPerson, + "04_you_gave_cash" to GaveCash, + "05_you_received_cash" to ReceivedCash, + "06_you_sent_cash_link" to SentCashLink, + "07_buy" to Buy, + "08_sell" to Sell, + "09_withdraw" to Withdraw, + "10_deposit" to Deposit, + "11_convert" to Convert, + "12_unknown" to Unknown, + "13_open_cash_link" to OpenCashLink, + ) +} diff --git a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsScreenshotTest.kt b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsScreenshotTest.kt new file mode 100644 index 000000000..80beb57c4 --- /dev/null +++ b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsScreenshotTest.kt @@ -0,0 +1,142 @@ +package com.flipcash.shared.transactionhistory + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import coil3.ColorImage +import coil3.Image +import coil3.ImageLoader +import coil3.SingletonImageLoader +import coil3.asImage +import coil3.decode.DataSource +import coil3.intercept.Interceptor +import coil3.request.ImageResult +import coil3.request.SuccessResult +import com.flipcash.app.theme.FlipcashPreview +import com.getcode.opencode.compose.LocalExchange +import com.getcode.opencode.compose.ExchangeStub +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +/** + * Renders every [TransactionKind] of [TransactionDetailsContent] to a PNG in `build/screenshots/`, + * so the states can be eyeballed against Figma 9708:105260 without an emulator. Not an assertion + * test. + * + * The screen never goes idle (Coil's images, the preview wrapper's SharedTransitionLayout), so + * `captureToImage()`'s implicit `waitForIdle` would hang. It pauses the clock, pumps a fixed number + * of frames, and draws the Android view directly — the same path + * `TokenCardWatermarkScreenshotTest` takes. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w400dp-h860dp-xhdpi") +class TransactionDetailsScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + /** + * Serve every image from memory, synchronously. + * + * The sample mints point at unreachable URLs, and Coil's real pipeline is asynchronous even + * when it is only going to fail — which is why one render could come out with its token icons + * missing while an identically-shaped one next to it had them. An interceptor that answers from + * [DataSource.MEMORY] takes the async path out entirely, so what lands in the PNG no longer + * depends on how many frames were pumped. + */ + @Before + fun stubImageLoader() { + SingletonImageLoader.setSafe { context -> + ImageLoader.Builder(context) + .components { + add( + Interceptor { chain -> + SuccessResult( + image = tokenArt(chain.request.data) + ?: ColorImage(color = 0x00000000), + request = chain.request, + dataSource = DataSource.MEMORY, + ) as ImageResult + }, + ) + } + .build() + } + } + + @Test fun youTipped() = render(0) + @Test fun youReceived() = render(1) + @Test fun youSent() = render(2) + @Test fun youGaveCash() = render(3) + @Test fun youReceivedCash() = render(4) + @Test fun youSentCashLink() = render(5) + @Test fun buy() = render(6) + @Test fun sell() = render(7) + @Test fun withdraw() = render(8) + @Test fun deposit() = render(9) + @Test fun convert() = render(10) + @Test fun unknown() = render(11) + @Test fun openCashLink() = render(12) + + private fun render(index: Int) { + val (name, details) = TransactionDetailsSamples.All[index] + + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + Harness { TransactionDetailsContent(details = details) } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("txn_details_$name.png") + } + + @Composable + private fun Harness(content: @Composable () -> Unit) { + FlipcashPreview(showBackground = true) { + CompositionLocalProvider( + LocalExchange provides ExchangeStub(context = LocalContext.current), + ) { + content() + } + } + } + + /** + * Each mint's own icon, decoded from `src/test/resources/tokens/`. The app draws token art from + * the mint's [com.getcode.opencode.model.financial.Token.imageUrl], never from a bundled + * drawable, so the renders resolve the fixtures' URLs to the tokens' real icons rather than + * standing in an app asset that belongs to a different screen. + */ + private fun tokenArt(data: Any?): Image? { + val file = data.toString().substringAfterLast("/tokens/", missingDelimiterValue = "") + .takeIf { it.isNotEmpty() } ?: return null + val bytes = javaClass.getResourceAsStream("/tokens/$file")?.use { it.readBytes() } ?: return null + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImage() + } + + private fun capture(name: String) { + val root: View = composeRule.activity.findViewById(android.R.id.content) + val width = root.width.takeIf { it > 0 } ?: 800 + val height = root.height.takeIf { it > 0 } ?: 1720 + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + + val outDir = File("build/screenshots").apply { mkdirs() } + val file = File(outDir, name) + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${bitmap.width}x${bitmap.height})") + } +} diff --git a/apps/flipcash/shared/transaction-history/src/test/resources/tokens/dollars.webp b/apps/flipcash/shared/transaction-history/src/test/resources/tokens/dollars.webp new file mode 100644 index 0000000000000000000000000000000000000000..a6c603a9e818523f4cbbd140fed3db08c2379cf7 GIT binary patch literal 18782 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
  • D%FwVcYTkJ97R4a#+Hs3^S@i9}I5Mc&0mBc(<;k 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 ZQmQA;=3pQ>6hr&~m`tFk2mk;8001R^`?>%C literal 0 HcmV?d00001 diff --git a/apps/flipcash/shared/transaction-history/src/test/resources/tokens/jeffy.png b/apps/flipcash/shared/transaction-history/src/test/resources/tokens/jeffy.png new file mode 100644 index 0000000000000000000000000000000000000000..67e55ac2f60460f9a07dce3c2c1c6555e5c18a2f GIT binary patch literal 7418 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