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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ data class ActivityFeedMessage(
val amount: LocalFiat?,
val timestamp: Instant,
val state: MessageState,
val metadata: MessageMetadata?
val metadata: MessageMetadata?,
// Ordered substitutions for [text]'s indexed placeholders ({0}, {1}, …). Persisted so the
// title can be resolved live at display time (see TransactionItemMapper), keeping it consistent
// with the live-resolved counterparty avatar.
val textSubstitutions: List<MessageSubstitution> = emptyList(),
) {
val isTransaction: Boolean
get() = amount != null
Expand All @@ -33,6 +37,41 @@ data class ActivityFeedMessage(
}
}

/**
* A substitution for an indexed placeholder in [ActivityFeedMessage.text]. The core-domain,
* persisted mirror of `com.flipcash.services.models.Substitution` (mapped at the persistence
* boundary, like [MessageMetadata]). [fallback] is the server-provided name; the client prefers
* a live-resolved name for [UserId] where available.
*/
@Serializable
sealed interface MessageSubstitution {
val fallback: String

@Serializable
data class Phone(
override val fallback: String,
val phoneNumber: String,
) : MessageSubstitution

@Serializable
data class UserId(
override val fallback: String,
val userId: ID,
) : MessageSubstitution

companion object {
/** Deserializes the persisted JSON list; empty (never throws) on null/garbage. */
fun listFrom(json: String?): List<MessageSubstitution> {
json ?: return emptyList()
return try {
Json.decodeFromString<List<MessageSubstitution>>(json)
} catch (e: Exception) {
emptyList()
}
}
}
}

enum class MessageState {
UNKNOWN,
PENDING,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,15 @@ import kotlin.collections.forEach

/**
* A vertical stack of [TokenCard]s that fans out (each card revealing its [fannedReveal] header) and
* **sticks per-card** on scroll: as a card scrolls above the viewport top it pins into a growing
* deck at the top ([collapsedReveal] per pinned card) while the cards below stay fanned and readable.
* **collapses, then scrolls off**: as the stack scrolls up, cards pin at the top and tighten from the
* fanned gap to [collapsedReveal] (a growing deck); once fully collapsed the deck **releases** and
* scrolls off the top with the rest of the list (it does not pin permanently).
*
* The stack's measured height is always the *fanned* height, so the enclosing list scrolls stably
* (cards are only repositioned, never resized — no feedback into the scroll range). Each card sits
* at `max(fannedY, pinnedY)`, a continuous transition from fanned to pinned with no jump.
*
* [scrolledPast] = how many px the stack's top has scrolled above the viewport top (`-itemOffset`);
* a lambda so the layout re-reads it on scroll without recomposing the whole stack. Cards are drawn
* front-to-back so the last (highest-value) card sits on top.
* The measured height is always the *fanned* height, so the enclosing list's scroll range is stable
* (cards are only repositioned, never resized). [scrolledPast] = px the stack's top has scrolled above
* the viewport top (`-itemOffset`); it is read in the **placement** phase so scrolling only re-places
* the cards and never re-measures/re-composes them. [pinInset] holds the collapsing deck below top
* chrome (e.g. the status bar). Cards are drawn front-to-back so the last (highest-value) sits on top.
*/
@Composable
fun TokenCardStack(
Expand All @@ -47,15 +46,22 @@ fun TokenCardStack(
) { measurables, constraints ->
val fannedPx = fannedReveal.roundToPx()
val collapsedPx = collapsedReveal.roundToPx()
// [pinInset] holds the pinned deck below any top chrome (e.g. the status bar) once cards stick.
val pinInsetPx = pinInset.roundToPx()
// Not clamped to ≥0: a negative value (stack below the pin line) keeps cards fanned flush.
val past = scrolledPast()
val placeables = measurables.map { it.measure(constraints.copy(minHeight = 0)) }
val cardPx = placeables.firstOrNull()?.height ?: 0
// Always the fanned height, so the list scroll range is stable while cards pin.
// Always the fanned height, so the list's scroll range is stable while cards collapse.
val height = if (placeables.isEmpty()) 0 else cardPx + fannedPx * (placeables.size - 1)
// Scroll distance at which every card has finished collapsing (the last card pins last).
val collapseComplete =
((placeables.size - 1) * (fannedPx - collapsedPx) - pinInsetPx).coerceAtLeast(0)
layout(constraints.maxWidth, height) {
// Read scroll offset HERE (placement) — not in the measure scope — so scrolling only
// re-places the cards; reading it while measuring would re-run each card's SubcomposeLayout.
// Cap only the UPPER bound at collapseComplete: once fully collapsed the deck stops pinning
// and the frozen layout scrolls off with the list. The value is intentionally allowed to go
// negative — at rest the stack sits below the top chrome, and that negative keeps `pinnedY`
// under `fannedY` so every card (including the last) stays fanned instead of collapsing.
val past = scrolledPast().coerceAtMost(collapseComplete.toFloat())
placeables.forEachIndexed { index, placeable ->
val fannedY = index * fannedPx
val pinnedY = (past + pinInsetPx + index * collapsedPx).toInt()
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 @@ -423,6 +423,7 @@

<string name="title_advancedFeatures">Advanced</string>
<string name="title_wallet">Wallet</string>
<string name="title_recentActivity">Recent</string>
<string name="action_wallet">Wallet</string>

<string name="action_sendAsLink">Send as a Link</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.flipcash.app.balance.internal.BalanceScreen
import com.flipcash.app.balance.internal.BalanceViewModel
import com.flipcash.app.balance.internal.WalletViewModel
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.tokens.TokenPurpose
import com.flipcash.app.tokens.ui.SelectTokenViewModel
Expand All @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach

@Deprecated("Replaced by WalletScreen in new UI")
@Composable
fun BalanceScreen() {
val navigator = LocalCodeNavigator.current
Expand All @@ -37,7 +38,7 @@ fun BalanceScreen() {
}
)

val viewModel = hiltViewModel<BalanceViewModel>()
val viewModel = hiltViewModel<WalletViewModel>()
val tokenViewModel = hiltViewModel<SelectTokenViewModel>()
BalanceScreen(viewModel, tokenViewModel)

Expand All @@ -51,15 +52,15 @@ fun BalanceScreen() {

LaunchedEffect(viewModel) {
viewModel.eventFlow
.filterIsInstance<BalanceViewModel.Event.OpenCurrencySelection>()
.filterIsInstance<WalletViewModel.Event.OpenCurrencySelection>()
.onEach {
navigator.push(AppRoute.Main.RegionSelection)
}.launchIn(this)
}

LaunchedEffect(viewModel) {
viewModel.eventFlow
.filterIsInstance<BalanceViewModel.Event.OpenScreen>()
.filterIsInstance<WalletViewModel.Event.OpenScreen>()
.map { it.screen }
.onEach { navigator.push(it) }
.launchIn(this)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.flipcash.app.balance.internal.BalanceViewModel
import com.flipcash.app.balance.internal.WalletViewModel
import com.flipcash.app.balance.internal.WalletScreen
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.extensions.openAsSheet
Expand All @@ -26,7 +26,7 @@ fun WalletScreen() {
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
val viewModel = hiltViewModel<BalanceViewModel>()
val viewModel = hiltViewModel<WalletViewModel>()
val tokenViewModel = hiltViewModel<SelectTokenViewModel>()
WalletScreen(viewModel, tokenViewModel)

Expand All @@ -40,15 +40,15 @@ fun WalletScreen() {

LaunchedEffect(viewModel) {
viewModel.eventFlow
.filterIsInstance<BalanceViewModel.Event.OpenCurrencySelection>()
.filterIsInstance<WalletViewModel.Event.OpenCurrencySelection>()
.onEach {
navigator.openAsSheet(AppRoute.Main.RegionSelection)
}.launchIn(this)
}

LaunchedEffect(viewModel) {
viewModel.eventFlow
.filterIsInstance<BalanceViewModel.Event.OpenScreen>()
.filterIsInstance<WalletViewModel.Event.OpenScreen>()
.map { it.screen }
.onEach { navigator.openAsSheet(it) }
.launchIn(this)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flipcash.app.balance.internal.components.BalanceHeader
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.tokens.TokenPurpose
import com.flipcash.app.theme.FlipcashPreview
import com.flipcash.app.tokens.ui.SelectTokenViewModel
import com.flipcash.app.core.ui.rememberTokenBalanceRowStyling
import com.flipcash.app.theme.FlipcashThemeWrapper
Expand All @@ -42,7 +41,7 @@ import com.getcode.ui.theme.CodeButton

@Composable
internal fun BalanceScreen(
viewModel: BalanceViewModel,
viewModel: WalletViewModel,
tokenViewModel: SelectTokenViewModel,
) {
val balanceState by viewModel.stateFlow.collectAsStateWithLifecycle()
Expand All @@ -56,7 +55,7 @@ internal fun BalanceScreen(
@Composable
internal fun BalanceScreenContent(
tokenState: SelectTokenViewModel.State,
dispatchEvent: (BalanceViewModel.Event) -> Unit
dispatchEvent: (WalletViewModel.Event) -> Unit
) {
Column {
val tokens = remember(tokenState.tokens) { tokenState.tokens }
Expand All @@ -69,7 +68,7 @@ internal fun BalanceScreenContent(
balance = tokenState.totalBalance,
appreciation = tokenState.aggregateAppreciation,
) {
dispatchEvent(BalanceViewModel.Event.OpenCurrencySelection)
dispatchEvent(WalletViewModel.Event.OpenCurrencySelection)
}

Spacer(modifier = Modifier.padding(CodeTheme.dimens.grid.x2))
Expand Down Expand Up @@ -115,7 +114,7 @@ internal fun BalanceScreenContent(

CodeButton(
onClick = {
dispatchEvent(BalanceViewModel.Event.PresentDepositOptions)
dispatchEvent(WalletViewModel.Event.PresentDepositOptions)
},
modifier = Modifier
.padding(top = CodeTheme.dimens.grid.x2)
Expand All @@ -140,7 +139,7 @@ internal fun BalanceScreenContent(
buttonState = ButtonState.Filled10,
onClick = {
dispatchEvent(
BalanceViewModel.Event.PresentDepositOptions
WalletViewModel.Event.PresentDepositOptions
)
}
)
Expand All @@ -149,7 +148,7 @@ internal fun BalanceScreenContent(
tokens = tokens,
onTokenSelected = {
dispatchEvent(
BalanceViewModel.Event.OpenScreen(
WalletViewModel.Event.OpenScreen(
AppRoute.Token.Info(it.address)
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package com.flipcash.app.balance.internal
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
Expand All @@ -15,26 +14,18 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddCircleOutline
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.ui.TokenCardStack
Expand All @@ -44,13 +35,14 @@ import com.flipcash.app.balance.internal.components.OnboardingItem
import com.flipcash.app.core.navigation.LocalTabBarPadding
import com.flipcash.app.tokens.ui.SelectTokenViewModel
import com.flipcash.features.balance.R
import com.flipcash.shared.transactionhistory.ActivityFeedRow
import com.getcode.theme.CodeTheme

private const val TokenStackKey = "tokenStack"

@Composable
internal fun WalletScreen(
viewModel: BalanceViewModel,
viewModel: WalletViewModel,
tokenViewModel: SelectTokenViewModel,
) {
val balanceState by viewModel.stateFlow.collectAsStateWithLifecycle()
Expand All @@ -64,17 +56,14 @@ internal fun WalletScreen(

@Composable
internal fun WalletScreenContent(
balanceState: BalanceViewModel.State,
balanceState: WalletViewModel.State,
tokenState: SelectTokenViewModel.State,
dispatchEvent: (BalanceViewModel.Event) -> Unit
dispatchEvent: (WalletViewModel.Event) -> Unit,
) {
val listState = rememberLazyListState()
// Sticky per-card collapse: the fan scrolls normally (the stack keeps a fixed fanned height, so
// scrolling is stable) and each card pins to the top as it scrolls above the viewport, building a
// deck while the cards below stay fanned and readable. `scrolledPast` = px of the stack scrolled
// above the viewport top, read live so the stack re-lays-out its cards as the list scrolls.
// May be negative when the stack sits below the pin line (i.e. scrolled to the top) so cards
// fan flush there instead of staying stuck under the pin inset.
// Px the token stack has scrolled above the viewport top, read live so the stack collapses (then
// releases and scrolls off) as the list scrolls. A lambda so the stack reads it in its placement
// phase without recomposing.
val scrolledPast = {
listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == TokenStackKey }
?.let { -it.offset.toFloat() } ?: 0f
Expand All @@ -100,7 +89,7 @@ internal fun WalletScreenContent(
balance = tokenState.totalBalance,
appreciation = tokenState.aggregateAppreciation,
) {
dispatchEvent(BalanceViewModel.Event.OpenCurrencySelection)
dispatchEvent(WalletViewModel.Event.OpenCurrencySelection)
}
}

Expand All @@ -118,7 +107,7 @@ internal fun WalletScreenContent(
) { item ->
when (item) {
is OnboardingItem.AddMoney -> {
dispatchEvent(BalanceViewModel.Event.PresentDepositOptions)
dispatchEvent(WalletViewModel.Event.PresentDepositOptions)
}
is OnboardingItem.ScanTipCard -> {

Expand All @@ -137,7 +126,7 @@ internal fun WalletScreenContent(
scrolledPast = scrolledPast,
onCardClick = { token ->
dispatchEvent(
BalanceViewModel.Event.OpenScreen(
WalletViewModel.Event.OpenScreen(
AppRoute.Token.Info(mint = token.token.address)
)
)
Expand All @@ -151,7 +140,7 @@ internal fun WalletScreenContent(
Box(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = { dispatchEvent(BalanceViewModel.Event.PresentDepositOptions) })
.clickable(onClick = { dispatchEvent(WalletViewModel.Event.PresentDepositOptions) })
.padding(vertical = CodeTheme.dimens.inset),
contentAlignment = Alignment.Center,
) {
Expand All @@ -173,5 +162,27 @@ internal fun WalletScreenContent(
}
}
}

if (balanceState.transactions.isNotEmpty()) {
item(key = "recentHeader") {
Text(
text = stringResource(R.string.title_recentActivity),
style = CodeTheme.typography.screenTitle,
color = CodeTheme.colors.textMain,
modifier = Modifier.padding(
top = CodeTheme.dimens.grid.x4,
bottom = CodeTheme.dimens.grid.x1,
),
)
}
// Preview of the most recent activity (newest first); the full history lives on its own
// screen. The VM/coordinator already bounds this list, so just render it.
items(
items = balanceState.transactions,
key = { it.id },
) { item ->
ActivityFeedRow(item = item, modifier = Modifier.fillMaxWidth())
}
}
}
}
Loading
Loading