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 000000000..a6c603a9e Binary files /dev/null and b/apps/flipcash/shared/transaction-history/src/test/resources/tokens/dollars.webp differ 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 000000000..67e55ac2f Binary files /dev/null and b/apps/flipcash/shared/transaction-history/src/test/resources/tokens/jeffy.png differ