Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -105,6 +106,7 @@ fun appEntryProvider(
}
annotatedEntry<AppRoute.Sheets.ShareApp> { ShareAppScreen() }
annotatedEntry<AppRoute.Sheets.ActivityHistory> { ActivityHistoryScreen() }
annotatedEntry<AppRoute.Sheets.TransactionDetails> { key -> TransactionDetailsScreen(key.id) }
annotatedEntry<AppRoute.Sheets.Menu> { MenuScreen() }

// Messaging
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@
<string name="title_clipboardLabelDepositAddress">Deposit Address</string>
<string name="title_clipboardLabelPublicKey">Public Key</string>
<string name="title_clipboardLabelAccountId">Account ID</string>
<string name="title_clipboardLabelTransactionId">Transaction ID</string>
<string name="title_clipboardLabelPushToken">Push Token</string>

<string name="prompt_description_viewAccessKey">Your Access Key will grant access to your Flipcash account. Keep it private and safe</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
)
},
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class WalletLoadingStateTest {

private val aTransaction = TransactionListItem(
id = "1",
messageId = listOf<Byte>(1),
title = "Received",
timestamp = Instant.fromEpochSeconds(0),
avatar = TransactionAvatar.Generic(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
)
},
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<TransactionDetailsViewModel>()
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) },
)
}
Original file line number Diff line number Diff line change
@@ -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<TransactionDetailsViewModel.State, TransactionDetailsViewModel.Event>(
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<Event.CopyId>()
.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<Event.OnCancelRequested>()
.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<Event.CancelTransfer>()
.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 }
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<MessageEntity?>

fun observeMessageById(id: List<Byte>): Flow<MessageEntity?> = observeMessageById(id.base58)

@RawQuery
suspend fun queryDirectly(query: SupportSQLiteQuery): List<MessageEntity>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActivityFeedMessage?> =
FlipcashDatabase.observeInstance().flatMapLatest { database ->
database?.messageDao()?.observeMessageById(id)?.map { entity ->
entity?.let { messageEntityMapper.map(it) }
} ?: flowOf(null)
}

override suspend fun get(): List<ActivityFeedMessage> {
val result = db?.messageDao()?.getAllMessages() ?: return emptyList()
return result.map { messageEntityMapper.map(it) }
Expand Down
5 changes: 5 additions & 0 deletions apps/flipcash/shared/transaction-history/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
Loading
Loading