From f4ad670e3f30196e83dc4905b27af8667520bbcb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 17:18:23 -0400 Subject: [PATCH 01/30] fix(services): keep chat holdings summary in mint quarks HoldingsSummary.quarks is the total in the currency's own quarks, not USD. Wrapping it in Fiat valued it at 1e6 per unit instead of 1e10. Carry the raw Long so the UI can format a token amount. --- .../components/info/CurrencyInfoActions.kt | 30 +++++++++++++ .../info/CurrencyInfoActionsTest.kt | 45 +++++++++++++++++++ .../network/extensions/ProtobufToLocal.kt | 2 +- .../services/models/chat/HoldingsSummary.kt | 5 +-- .../internal/domain/ChatMetadataMapperTest.kt | 19 ++++++++ 5 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoActions.kt create mode 100644 apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoActionsTest.kt diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoActions.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoActions.kt new file mode 100644 index 0000000000..5f9995305b --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoActions.kt @@ -0,0 +1,30 @@ +package com.flipcash.app.tokens.internal.components.info + +/** An entry in Currency Info's action row. The order here is the order on screen. */ +internal enum class CurrencyInfoTile { Send, Buy, BuyIn, Transactions, More } + +/** A row of the More sheet. The order here is the order on screen. */ +internal enum class CurrencyInfoMoreRow { Convert, Withdraw, Deposit } + +/** + * Tiles per state: not held shows Buy In alone; USDF has no Buy; everything else is + * Send, Buy, Transactions, More. + */ +internal fun currencyInfoTiles(isHeld: Boolean, isUsdf: Boolean): List = when { + !isHeld -> listOf(CurrencyInfoTile.BuyIn) + isUsdf -> listOf(CurrencyInfoTile.Send, CurrencyInfoTile.Transactions, CurrencyInfoTile.More) + else -> listOf( + CurrencyInfoTile.Send, + CurrencyInfoTile.Buy, + CurrencyInfoTile.Transactions, + CurrencyInfoTile.More, + ) +} + +/** USDF is a reserve currency, so it has no per-mint Deposit. */ +internal fun currencyInfoMoreRows(isUsdf: Boolean): List = + if (isUsdf) { + listOf(CurrencyInfoMoreRow.Convert, CurrencyInfoMoreRow.Withdraw) + } else { + listOf(CurrencyInfoMoreRow.Convert, CurrencyInfoMoreRow.Withdraw, CurrencyInfoMoreRow.Deposit) + } diff --git a/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoActionsTest.kt b/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoActionsTest.kt new file mode 100644 index 0000000000..4478a75fe5 --- /dev/null +++ b/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoActionsTest.kt @@ -0,0 +1,45 @@ +package com.flipcash.app.tokens.internal.components.info + +import kotlin.test.Test +import kotlin.test.assertEquals + +class CurrencyInfoActionsTest { + @Test + fun `held non-usdf shows send buy transactions more`() { + assertEquals( + listOf( + CurrencyInfoTile.Send, + CurrencyInfoTile.Buy, + CurrencyInfoTile.Transactions, + CurrencyInfoTile.More, + ), + currencyInfoTiles(isHeld = true, isUsdf = false), + ) + } + + @Test + fun `held usdf has no buy`() { + assertEquals( + listOf(CurrencyInfoTile.Send, CurrencyInfoTile.Transactions, CurrencyInfoTile.More), + currencyInfoTiles(isHeld = true, isUsdf = true), + ) + } + + @Test + fun `not held shows buy in only`() { + assertEquals(listOf(CurrencyInfoTile.BuyIn), currencyInfoTiles(isHeld = false, isUsdf = false)) + assertEquals(listOf(CurrencyInfoTile.BuyIn), currencyInfoTiles(isHeld = false, isUsdf = true)) + } + + @Test + fun `more rows include deposit except for usdf`() { + assertEquals( + listOf(CurrencyInfoMoreRow.Convert, CurrencyInfoMoreRow.Withdraw, CurrencyInfoMoreRow.Deposit), + currencyInfoMoreRows(isUsdf = false), + ) + assertEquals( + listOf(CurrencyInfoMoreRow.Convert, CurrencyInfoMoreRow.Withdraw), + currencyInfoMoreRows(isUsdf = true), + ) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index 4a3b166e1b..df78ae12c9 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -503,7 +503,7 @@ internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata { internal fun ChatModel.HoldingsSummary.toHoldingsSummary(): HoldingsSummary { return HoldingsSummary( mint = mint.toMint(), - holdings = Fiat(quarks = quarks), + quarks = quarks, holderCount = holderCount, asOf = Instant.fromEpochSeconds(asOf.seconds, asOf.nanos), ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/HoldingsSummary.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/HoldingsSummary.kt index bd18981d61..85fd0cf021 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/HoldingsSummary.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/HoldingsSummary.kt @@ -1,6 +1,5 @@ package com.flipcash.services.models.chat -import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.Mint import kotlin.time.Instant @@ -8,13 +7,13 @@ import kotlin.time.Instant * What a group's members held of the currency the group is gated on, as of [asOf]. * * @param mint The currency the group is gated on - * @param holdings Total held by members, in the mint's quarks + * @param quarks Total held by members, in the mint's quarks (not a fiat amount) * @param holderCount Number of members who held any of the currency * @param asOf When the snapshot was computed */ data class HoldingsSummary( val mint: Mint, - val holdings: Fiat, + val quarks: Long, val holderCount: Long, val asOf: Instant, ) diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapperTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapperTest.kt index 3cafd4a720..9e014ee2ab 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapperTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapperTest.kt @@ -166,4 +166,23 @@ class ChatMetadataMapperTest { val result = mapper.map(metadata()) assertNull(result.lastMessage) } + + @Test + fun `maps holdings summary in mint quarks`() { + val summary = ChatModel.HoldingsSummary.newBuilder() + .setMint(Common.PublicKey.newBuilder().setValue(ByteString.copyFrom(ByteArray(32) { 7 }))) + .setQuarks(25_000_000_000L) + .setHolderCount(14) + .setAsOf(Timestamp.newBuilder().setSeconds(2000)) + .build() + val result = mapper.map(metadata { setHoldingsSummary(summary) }) + val mapped = assertNotNull(result.holdingsSummary) + assertEquals(25_000_000_000L, mapped.quarks) + assertEquals(14L, mapped.holderCount) + } + + @Test + fun `holdings summary null when absent`() { + assertNull(mapper.map(metadata()).holdingsSummary) + } } From 19dbc45b44c3a3f4632480c019404e65195b7b5a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 17:21:31 -0400 Subject: [PATCH 02/30] feat(tokens): load top community chats and add More sheet to token info TokenInfoViewModel fetches GetCommunity per mint and keeps the ranked chats, empty on failure or for USDF. OnMore shows Convert, Withdraw and (non-USDF) Deposit as card rows like the group Send/Request sheet. Deposit has no per-mint form yet, so it opens the account deposit. --- .../main/res/drawable/ic_more_horizontal.xml | 9 +++ .../src/main/res/drawable/ic_transactions.xml | 10 +++ .../core/src/main/res/values/strings.xml | 18 +++++ .../flipcash/app/tokens/ui/CommunityChats.kt | 51 ++++++++++++ .../app/tokens/ui/TokenInfoViewModel.kt | 78 +++++++++++++++++++ .../app/tokens/ui/CommunityChatsTest.kt | 76 ++++++++++++++++++ .../ui/TokenInfoViewModelChartLoadTest.kt | 3 + .../ui/TokenInfoViewModelConvertTest.kt | 3 + 8 files changed, 248 insertions(+) create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_more_horizontal.xml create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_transactions.xml create mode 100644 apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChats.kt create mode 100644 apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CommunityChatsTest.kt diff --git a/apps/flipcash/core/src/main/res/drawable/ic_more_horizontal.xml b/apps/flipcash/core/src/main/res/drawable/ic_more_horizontal.xml new file mode 100644 index 0000000000..8f09b7aca3 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_more_horizontal.xml @@ -0,0 +1,9 @@ + + + diff --git a/apps/flipcash/core/src/main/res/drawable/ic_transactions.xml b/apps/flipcash/core/src/main/res/drawable/ic_transactions.xml new file mode 100644 index 0000000000..7862b28904 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_transactions.xml @@ -0,0 +1,10 @@ + + + diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 6a02a9bb9f..57c0994e35 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1858,4 +1858,22 @@ %1$s and %2$d others reacted Someone + More + Transactions + Or Give As Cash + Convert %1$s to another community currency on Flipcash + Withdraw %1$s to a different Solana crypto wallet + Deposit %1$s from a different Solana crypto wallet + Top Community Chats + View All + How Chats Are Ranked + Chats are ranked by the total amount of %1$s held by their members. Chats with higher combined holdings appear higher. + OK + + %1$s of %2$s held by %3$d member + %1$s of %2$s held by %3$d members + + Select Recipient + Share %1$s + Recent Chats diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChats.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChats.kt new file mode 100644 index 0000000000..8494562c66 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChats.kt @@ -0,0 +1,51 @@ +package com.flipcash.app.tokens.ui + +import com.flipcash.services.models.Community +import com.flipcash.services.models.chat.ChatMetadata +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.util.Locale + +/** Chats Currency Info previews before "View All". */ +const val TopCommunityChatsPreviewCount = 3 + +private const val QuarksPerToken = 10_000_000_000L + +/** + * The chats a Currency Info page lists, in the server's ranking. A failed call and an empty list + * both give an empty list, which hides the section. + */ +fun Result.topCommunityChats(): List = + getOrNull()?.topHolderChats.orEmpty() + +/** The values behind a chat row's " of held by N members" line. */ +data class CommunityChatHoldings(val amount: String, val memberCount: Long) + +/** + * Null when the server sent no holdings for the chat, so the row shows no subtitle rather than a + * made-up zero. + */ +fun ChatMetadata.communityHoldings(locale: Locale = Locale.getDefault()): CommunityChatHoldings? { + val summary = holdingsSummary ?: return null + return CommunityChatHoldings( + amount = formatQuarksCompact(summary.quarks, locale), + memberCount = rosterSummary.memberCount, + ) +} + +/** A token amount from raw [quarks], abbreviated: 950, 1.2K, 3.4M, 5B. Rounds down. */ +fun formatQuarksCompact(quarks: Long, locale: Locale = Locale.getDefault()): String { + val tokens = BigDecimal(quarks).divide(BigDecimal(QuarksPerToken)) + val (scaled, suffix) = when { + tokens >= BigDecimal(1_000_000_000) -> tokens.movePointLeft(9) to "B" + tokens >= BigDecimal(1_000_000) -> tokens.movePointLeft(6) to "M" + tokens >= BigDecimal(1_000) -> tokens.movePointLeft(3) to "K" + else -> tokens to "" + } + val format = DecimalFormat("#,##0.#", DecimalFormatSymbols(locale)).apply { + roundingMode = RoundingMode.DOWN + } + return format.format(scaled) + suffix +} diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt index 0e4fb8c001..3c016e5504 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt @@ -15,6 +15,9 @@ import com.flipcash.app.tokens.data.MarketCapPoint import com.flipcash.app.tokens.data.Period import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.shared.tokens.R +import com.flipcash.app.funding.bottomBarCardAction +import com.flipcash.services.controllers.CommunityController +import com.flipcash.services.models.chat.ChatMetadata import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.AccountController @@ -52,6 +55,7 @@ class TokenInfoViewModel @Inject constructor( private val resources: ResourceHelper, private val purchaseMethodController: PurchaseMethodController, private val feedCoordinator: ActivityFeedCoordinator, + private val communityController: CommunityController, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -72,6 +76,8 @@ class TokenInfoViewModel @Inject constructor( val fundableBalanceMints: Set = emptySet(), /** Bounded per-token recent activity preview (newest first) for the v2 currency-info screen. */ val transactions: List = emptyList(), + /** Public groups gated on this currency, ranked by the server. Empty hides the section. */ + val communityChats: List = emptyList(), ) { /** True when the user holds a displayable balance in some token other than [mint], which could fund a buy of it. */ val hasFundableBalance: Boolean @@ -121,6 +127,8 @@ class TokenInfoViewModel @Inject constructor( data object Share : Event data class OnBuy(val shortFall: Fiat? = null) : Event data object OnConvert : Event + data object OnMore : Event + data class OnCommunityChatsLoaded(val chats: List) : Event data object PresentDepositOptions: Event data class OpenScreen(val screen: AppRoute) : Event data object Exit : Event @@ -379,6 +387,27 @@ class TokenInfoViewModel @Inject constructor( .onEach { route -> dispatchEvent(Event.OpenScreen(route)) } .launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .map { it.mint } + .distinctUntilChanged() + .flatMapLatest { mint -> + kotlinx.coroutines.flow.flow { + // USDF has no community; a failed call and an empty list both hide the section. + emit( + if (mint == Mint.usdf) emptyList() + else communityController.getCommunity(mint).topCommunityChats() + ) + } + } + .onEach { dispatchEvent(Event.OnCommunityChatsLoaded(it)) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { presentMore() } + .launchIn(viewModelScope) + eventFlow .filterIsInstance() .mapNotNull { stateFlow.value.token.dataOrNull } @@ -387,6 +416,53 @@ class TokenInfoViewModel @Inject constructor( .launchIn(viewModelScope) } + /** + * Convert, Withdraw and Deposit as card rows, styled as the group chat's Send/Request sheet: + * no title, no Dismiss. + */ + private fun presentMore() { + val state = stateFlow.value + val mint = state.mint ?: return + val name = state.token.dataOrNull?.name ?: return + val rows = if (mint == Mint.usdf) listOf(MoreRow.Convert, MoreRow.Withdraw) + else listOf(MoreRow.Convert, MoreRow.Withdraw, MoreRow.Deposit) + BottomBarManager.showMessage( + title = "", + actions = rows.map { row -> + when (row) { + MoreRow.Convert -> bottomBarCardAction( + title = resources.getString(R.string.action_convert), + subtitle = resources.getString(R.string.subtitle_moreConvert, name), + iconRes = R.drawable.ic_convert, + testTag = "more_action_convert", + onClick = { dispatchEvent(Event.OnConvert) }, + ) + MoreRow.Withdraw -> bottomBarCardAction( + title = resources.getString(R.string.action_withdraw), + subtitle = resources.getString(R.string.subtitle_moreWithdraw, name), + iconRes = R.drawable.ic_menu_withdraw, + testTag = "more_action_withdraw", + onClick = { + dispatchEvent( + Event.OpenScreen(AppRoute.Transfers.Withdrawal(preselectedMint = mint)) + ) + }, + ) + MoreRow.Deposit -> bottomBarCardAction( + title = resources.getString(R.string.action_deposit), + subtitle = resources.getString(R.string.subtitle_moreDeposit, name), + iconRes = R.drawable.ic_menu_deposit, + testTag = "more_action_deposit", + // No per-mint deposit exists yet: this opens the account's USDC deposit. + onClick = { dispatchEvent(Event.OpenScreen(AppRoute.Transfers.Deposit())) }, + ) + } + }, + ) + } + + private enum class MoreRow { Convert, Withdraw, Deposit } + companion object { /** Rows shown in the token info screen's per-token recent-activity preview. */ private const val RECENT_PREVIEW_COUNT = 3 @@ -423,6 +499,8 @@ class TokenInfoViewModel @Inject constructor( is Event.OnTransactionHistoryEnabled -> { state -> state.copy(showTransactionHistory = event.enabled) } is Event.OnMarketCapPeriodSelected -> { state -> state.copy(selectedPeriod = event.period) } + is Event.OnCommunityChatsLoaded -> { state -> state.copy(communityChats = event.chats) } + Event.OnMore -> { state -> state } is Event.OpenScreen -> { state -> state } is Event.OnBuy -> { state -> state } Event.OnConvert -> { state -> state } diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CommunityChatsTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CommunityChatsTest.kt new file mode 100644 index 0000000000..000b4b2a3f --- /dev/null +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CommunityChatsTest.kt @@ -0,0 +1,76 @@ +package com.flipcash.app.tokens.ui + +import com.flipcash.services.models.Community +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.HoldingsSummary +import com.flipcash.services.models.chat.RosterSummary +import com.getcode.solana.keys.Mint +import java.util.Locale +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +class CommunityChatsTest { + private val us = Locale.US + private val quarksPerToken = 10_000_000_000L + + private fun chat(holdings: HoldingsSummary?, members: Long = 14) = ChatMetadata( + chatId = ChatId(ByteArray(32)), + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(0), + rosterSummary = RosterSummary(memberCount = members, version = 1), + holdingsSummary = holdings, + ) + + @Test + fun `quarks format as abbreviated token amounts`() { + assertEquals("0", formatQuarksCompact(0, us)) + assertEquals("950", formatQuarksCompact(950 * quarksPerToken, us)) + assertEquals("1.2K", formatQuarksCompact(1_250 * quarksPerToken, us)) + assertEquals("3.4M", formatQuarksCompact(3_450_000 * quarksPerToken, us)) + assertEquals("500M", formatQuarksCompact(500_000_000 * quarksPerToken, us)) + } + + @Test + fun `row text uses holdings quarks and roster member count`() { + val summary = HoldingsSummary( + mint = Mint.usdf, + quarks = 1_250 * quarksPerToken, + holderCount = 9, + asOf = Instant.fromEpochSeconds(0), + ) + assertEquals( + CommunityChatHoldings(amount = "1.2K", memberCount = 14), + chat(summary).communityHoldings(us), + ) + } + + @Test + fun `row has no holdings text when the server sent none`() { + assertNull(chat(null).communityHoldings(us)) + } + + @Test + fun `failed call hides the section`() { + assertEquals(emptyList(), Result.failure(Throwable("boom")).topCommunityChats()) + } + + @Test + fun `empty community hides the section`() { + val community = Community(mint = Mint.usdf, name = "x", leader = null, topHolderChats = emptyList()) + assertEquals(emptyList(), Result.success(community).topCommunityChats()) + } + + @Test + fun `chats keep the server's ranking`() { + val a = chat(null, 1) + val b = chat(null, 2) + val community = Community(mint = Mint.usdf, name = "x", leader = null, topHolderChats = listOf(a, b)) + assertEquals(listOf(a, b), Result.success(community).topCommunityChats()) + } +} diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelChartLoadTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelChartLoadTest.kt index 0a12d6ed2f..156b3a9b96 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelChartLoadTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelChartLoadTest.kt @@ -90,6 +90,9 @@ class TokenInfoViewModelChartLoadTest { resources = FakeResourceHelper(), purchaseMethodController = mockk(relaxed = true), feedCoordinator = mockk(relaxed = true), + communityController = mockk { + io.mockk.coEvery { getCommunity(any()) } returns Result.failure(Throwable("none")) + }, dispatchers = TestDispatchers(testScheduler), ) diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelConvertTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelConvertTest.kt index ce80126f59..ff7eb9e840 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelConvertTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModelConvertTest.kt @@ -100,6 +100,9 @@ class TokenInfoViewModelConvertTest { resources = FakeResourceHelper(), purchaseMethodController = mockk(relaxed = true), feedCoordinator = mockk(relaxed = true), + communityController = mockk { + io.mockk.coEvery { getCommunity(any()) } returns Result.failure(Throwable("none")) + }, dispatchers = TestDispatchers(testScheduler), ) vm.dispatchEvent(TokenInfoViewModel.Event.OnMintProvided(mint)) From 66ebf91eff461d92ba3dc7e1ab7be30127ace386 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 17:24:38 -0400 Subject: [PATCH 03/30] feat(share): add Share to chats sheet for currency links --- .../ui/navigation/AppScreenContent.kt | 2 ++ .../kotlin/com/flipcash/app/core/AppRoute.kt | 8 +++++ .../messenger/ShareProfileToChatsScreen.kt | 34 +++++++++++++++++++ .../internal/screens/GroupInviteSheet.kt | 20 ++++++----- 4 files changed, 56 insertions(+), 8 deletions(-) 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 7aeb01df63..a6ce573dc3 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 @@ -48,6 +48,7 @@ import com.flipcash.app.messenger.E2eeLearnMoreScreen import com.flipcash.app.messenger.MuteChatScreen import com.flipcash.app.messenger.ProfileScreen import com.flipcash.app.messenger.SharePickerScreen +import com.flipcash.app.messenger.ShareCurrencyToChatsScreen import com.flipcash.app.messenger.ShareProfileToChatsScreen import com.flipcash.app.messenger.ReportFlowScreen import com.flipcash.app.discovery.TokenDiscoveryScreen @@ -169,6 +170,7 @@ internal fun appEntryProvider( } annotatedEntry { SharePickerScreen() } annotatedEntry { key -> ShareProfileToChatsScreen(key) } + annotatedEntry { key -> ShareCurrencyToChatsScreen(key) } annotatedEntry { key -> MuteChatScreen(key.chatId, key.chatType) } annotatedEntry { E2eeLearnMoreScreen(forGroup = false) } annotatedEntry { E2eeLearnMoreScreen(forGroup = true) } 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 9c41d4c450..18f916da82 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 @@ -549,6 +549,14 @@ sealed interface AppRoute : NavKey, Parcelable { val directChatId: ChatId?, ) : Messaging, com.getcode.navigation.Sheet, com.getcode.navigation.FullscreenSheet + /** "Share " from Currency Info: the page link shared, copied, or sent into recent chats. */ + @Serializable + @Parcelize + data class ShareCurrencyToChats( + val mint: Mint, + val name: String, + ) : Messaging, com.getcode.navigation.Sheet, com.getcode.navigation.FullscreenSheet + /** * What end-to-end encryption covers in a DM, opened from the DM profile's footer. A * [com.getcode.navigation.WrapContentSheet], for the reason [MuteChat] is one. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ShareProfileToChatsScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ShareProfileToChatsScreen.kt index ebd897e1d1..fd39ab51b4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ShareProfileToChatsScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ShareProfileToChatsScreen.kt @@ -77,3 +77,37 @@ fun ShareProfileToChatsScreen(route: AppRoute.Messaging.ShareProfileToChats) { }, ) } + +/** Currency Info's "Share " sheet, behind `AppRoute.Messaging.ShareCurrencyToChats`. */ +@Composable +fun ShareCurrencyToChatsScreen(route: AppRoute.Messaging.ShareCurrencyToChats) { + val viewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + val navigator = LocalCodeNavigator.current + val dismissSheet = LocalBottomSheetDismissDispatcher.current + val keyboard = rememberKeyboardController() + + val link = remember(route.mint) { Linkify.tokenInfo(route.mint) } + + LaunchedEffect(viewModel) { + viewModel.invited.collect { chatId -> + keyboard.hideIfVisible { + navigator.pendingSheetDismiss = { + navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId))) + } + } + } + } + + GroupInviteSheet( + inviteUrl = link, + subject = ShareToChatsSubject.Currency(route.name), + state = state, + onShare = {}, + onCopy = { viewModel.copyProfileLink(link) }, + onToggle = viewModel::toggle, + onMessageChanged = viewModel::onMessageChanged, + onInvite = { viewModel.invite(link) }, + onDismiss = dismissSheet, + ) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index 8b75679d32..157e4fd30c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -138,7 +138,7 @@ internal fun cardTileTag(subject: ShareToChatsSubject): String? = * link has no card. */ internal fun showsCardTile(subject: ShareToChatsSubject, onShowCard: (() -> Unit)?): Boolean = - onShowCard != null && subject !is ShareToChatsSubject.Message + onShowCard != null && subject !is ShareToChatsSubject.Message && subject !is ShareToChatsSubject.Currency /** * What [GroupInviteSheet] hands out: a group's invite link, a person's profile link, or a link to @@ -167,6 +167,9 @@ internal sealed interface ShareToChatsSubject { * carried: the recipient's app draws the message card from it. */ data object Message : ShareToChatsSubject + + /** A currency's page link, from Currency Info. Like a message link it has no card to show. */ + data class Currency(val name: String) : ShareToChatsSubject } /** @@ -294,6 +297,7 @@ internal fun GroupInviteSheet( preview = subject.preview, ) ShareToChatsSubject.Message -> Shareable.MessageLink(inviteUrl) + is ShareToChatsSubject.Currency -> Shareable.MessageLink(inviteUrl) } ) } @@ -385,13 +389,13 @@ internal fun GroupInviteSheet( ) } .onSizeChanged { headerHeight = with(density) { it.height.toDp() } }, - title = stringResource( - when (subject) { - is ShareToChatsSubject.User -> R.string.title_shareProfileToChats - is ShareToChatsSubject.Message -> R.string.title_shareMessageToChats - is ShareToChatsSubject.Group -> R.string.title_invitePeople - } - ), + title = when (subject) { + is ShareToChatsSubject.Currency -> + stringResource(R.string.title_shareCurrency, subject.name) + is ShareToChatsSubject.User -> stringResource(R.string.title_shareProfileToChats) + is ShareToChatsSubject.Message -> stringResource(R.string.title_shareMessageToChats) + is ShareToChatsSubject.Group -> stringResource(R.string.title_invitePeople) + }, titleAlignment = Alignment.CenterHorizontally, contentPadding = PaddingValues(HeaderPadding), endContent = { AppBarDefaults.Close(hazeState = hazeState, onClick = onDismiss) }, From fe7b9dce02a07afc526ee9f23629ed4f16115ba4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 17:30:31 -0400 Subject: [PATCH 04/30] feat(cash): give a preset amount straight to the bill --- .../internal/ui/navigation/AppScreenContent.kt | 2 +- .../kotlin/com/flipcash/app/core/AppRoute.kt | 17 ++++++++++++++++- .../kotlin/com/flipcash/app/cash/CashScreen.kt | 17 ++++++++++++++++- 3 files changed, 33 insertions(+), 3 deletions(-) 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 a6ce573dc3..445204e1a1 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 @@ -125,7 +125,7 @@ internal fun appEntryProvider( // Sheets (inner content — wrapped in Main.Sheet by navigateTo()) // Route type is `Give` but the screen is the Cash/Give screen the flows call cash_screen. - annotatedEntry(testTag = "cash_screen") { key -> CashScreen(key.mint, key.fromTokenInfo) } + annotatedEntry(testTag = "cash_screen") { key -> CashScreen(key.mint, key.fromTokenInfo, key.amount) } annotatedEntry { key -> TokenSelectScreen(key.purpose) } annotatedEntry { ShareAppScreen() } annotatedEntry { ActivityHistoryScreen() } 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 18f916da82..38c155fdc0 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 @@ -196,7 +196,22 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data class TokenSelection(val purpose: TokenPurpose) : Sheets @Serializable - data class Give(val mint: Mint? = null, val fromTokenInfo: Boolean = false) : Sheets + data class Give( + val mint: Mint? = null, + val fromTokenInfo: Boolean = false, + /** + * An amount in the viewer's preferred currency to give as-is. When set, the screen + * skips its own amount entry and goes straight to the bill. + */ + val amount: Double? = null, + ) : Sheets + + /** + * Send from Currency Info: an amount of [mint], then a recipient from the viewer's DMs. + * Dismisses back to Currency Info after the send. + */ + @Serializable + data class CurrencySend(val mint: Mint) : Sheets /** Full unified paged activity history — the "dive in" from the wallet's recent-activity preview. */ @Serializable diff --git a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt index 3548bfc69c..b1adcbd5e1 100644 --- a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt +++ b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt @@ -1,5 +1,6 @@ package com.flipcash.app.cash +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -22,6 +23,7 @@ import com.getcode.solana.keys.Mint import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -30,6 +32,8 @@ import kotlinx.coroutines.flow.onEach fun CashScreen( selectedMint: Mint?, fromTokenInfo: Boolean, + // Set when another screen already collected the amount: the bill is made for it, with no entry. + presetAmount: Double? = null, ) { val navigator = LocalCodeNavigator.current val session = LocalSessionController.current!! @@ -53,7 +57,18 @@ fun CashScreen( .launchIn(this) } - Column( + if (presetAmount != null) { + // The entry is never shown: once the token and currency have loaded, type the amount into + // the delegate and give, the same path the Next button takes. + LaunchedEffect(viewModel, presetAmount) { + viewModel.stateFlow + .first { it.token != null && it.currencyModel.selected != null } + viewModel.amountDelegate.state.first { it.currency.selected != null } + viewModel.amountDelegate.prefill(presetAmount) + viewModel.dispatchEvent(CashScreenViewModel.Event.OnGive) + } + Box(modifier = Modifier.fillMaxSize()) + } else Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { From a03aa9f583720a479b174791a4cf73d2ce188191 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 17:32:56 -0400 Subject: [PATCH 05/30] feat(messenger): send a currency to a DM recipient from Currency Info --- .../ui/navigation/AppScreenContent.kt | 2 + .../app/core/ui/TokenSelectionPill.kt | 19 +- .../app/messenger/CurrencySendScreen.kt | 328 ++++++++++++++++++ .../currencysend/CurrencySendRecipients.kt | 26 ++ .../currencysend/CurrencySendViewModel.kt | 248 +++++++++++++ .../CurrencySendRecipientsTest.kt | 67 ++++ 6 files changed, 683 insertions(+), 7 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendRecipients.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendRecipientsTest.kt 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 445204e1a1..64cdc93195 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 @@ -44,6 +44,7 @@ import com.flipcash.app.currency.RegionSelectionScreen import com.flipcash.app.deposit.DepositFlowScreen import com.flipcash.app.invite.InviteContactScreen import com.flipcash.app.messenger.ChatFlowScreen +import com.flipcash.app.messenger.CurrencySendScreen import com.flipcash.app.messenger.E2eeLearnMoreScreen import com.flipcash.app.messenger.MuteChatScreen import com.flipcash.app.messenger.ProfileScreen @@ -125,6 +126,7 @@ internal fun appEntryProvider( // Sheets (inner content — wrapped in Main.Sheet by navigateTo()) // Route type is `Give` but the screen is the Cash/Give screen the flows call cash_screen. + annotatedEntry(testTag = "currency_send_screen") { key -> CurrencySendScreen(key.mint) } annotatedEntry(testTag = "cash_screen") { key -> CashScreen(key.mint, key.fromTokenInfo, key.amount) } annotatedEntry { key -> TokenSelectScreen(key.purpose) } annotatedEntry { ShareAppScreen() } diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenSelectionPill.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenSelectionPill.kt index ec82200dd9..2789f49c0a 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenSelectionPill.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenSelectionPill.kt @@ -36,6 +36,9 @@ fun TokenSelectionPill( ), textStyle: TextStyle = CodeTheme.typography.screenTitle, imageSize: Dp = CodeTheme.dimens.staticGrid.x5, + // False draws the currency alone, with no dropdown mark and nothing to tap, for a flow that is + // locked to one currency. + showsPicker: Boolean = true, onClick: () -> Unit ) { // [contentPadding] only applies when a background is supplied, so call sites without one @@ -45,7 +48,7 @@ fun TokenSelectionPill( modifier = modifier .clip(CircleShape) .background(color = background, shape = CircleShape) - .clickable { onClick() } + .clickable(enabled = showsPicker) { onClick() } .then(if (hasBackground) Modifier.padding(contentPadding) else Modifier), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy( @@ -61,12 +64,14 @@ fun TokenSelectionPill( textStyle = textStyle, ) - Image( - modifier = Modifier - .size(CodeTheme.dimens.grid.x4), - painter = painterResource(R.drawable.ic_dropdown), - contentDescription = "" - ) + if (showsPicker) { + Image( + modifier = Modifier + .size(CodeTheme.dimens.grid.x4), + painter = painterResource(R.drawable.ic_dropdown), + contentDescription = "" + ) + } } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt new file mode 100644 index 0000000000..5c4f0532d0 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt @@ -0,0 +1,328 @@ +package com.flipcash.app.messenger + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.clickable +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.ui.TokenSelectionPill +import com.flipcash.app.messenger.internal.currencysend.CurrencySendViewModel +import com.flipcash.features.messenger.R +import com.flipcash.shared.amountentry.AmountEntryScreen +import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.theme.CodeTheme +import com.getcode.theme.White10 +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.SearchInput +import com.getcode.ui.components.SlideToConfirm +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import com.getcode.solana.keys.Mint + +/** + * Send a currency to a person from its Currency Info page, behind [AppRoute.Sheets.CurrencySend]: + * the amount, a recipient from the viewer's DMs, then the swipe. The currency is fixed to [mint]. + * After the checkmark the screen pops back to Currency Info. + */ +@Composable +fun CurrencySendScreen(mint: Mint) { + val viewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + val navigator = LocalCodeNavigator.current + + LaunchedEffect(viewModel, mint) { viewModel.start(mint) } + LaunchedEffect(viewModel) { viewModel.done.collect { navigator.pop() } } + + BackHandler(enabled = state.stage == CurrencySendViewModel.Stage.Recipients && state.selected == null) { + viewModel.onBackToAmount() + } + + AnimatedContent( + targetState = state.stage, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "currencySendStage", + ) { stage -> + when (stage) { + CurrencySendViewModel.Stage.Amount -> AmountEntryScreen( + controller = viewModel.amountDelegate, + onConfirm = viewModel::onAmountConfirmed, + largeHeader = true, + appBar = { + AppBarWithTitle( + titleAlignment = Alignment.CenterHorizontally, + titleContent = { + // Locked to this currency: no dropdown mark, and nothing to tap. + TokenSelectionPill( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + token = state.token, + showsPicker = false, + ) {} + }, + onBackIconClicked = { navigator.pop() }, + ) + }, + ) + + CurrencySendViewModel.Stage.Recipients -> SelectRecipientContent( + recipients = state.visibleRecipients, + onQueryChanged = viewModel::onQueryChanged, + onBack = viewModel::onBackToAmount, + onPick = viewModel::onRecipientTapped, + onGiveAsCash = { + val amount = state.amount ?: return@SelectRecipientContent + navigator.push( + AppRoute.Sheets.Give( + mint = mint, + fromTokenInfo = true, + amount = amount.decimalValue, + ) + ) + }, + ) + } + } + + val selected = state.selected + val amount = state.amount + if (selected != null && amount != null) { + SwipeToSendSheet( + recipient = selected, + amountText = amount.formatted(), + token = state.token, + sendProgress = state.sendProgress, + onConfirm = viewModel::send, + onDismiss = viewModel::onConfirmDismissed, + ) + } +} + +@Composable +private fun SelectRecipientContent( + recipients: List?, + onQueryChanged: (String) -> Unit, + onBack: () -> Unit, + onPick: (ConversationReference) -> Unit, + onGiveAsCash: () -> Unit, +) { + val query: TextFieldState = rememberTextFieldState() + LaunchedEffect(query) { + snapshotFlow { query.text.toString() }.collect(onQueryChanged) + } + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_selectRecipient), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = onBack, + ) + }, + bottomBar = { + // Pinned, and present with an empty list too: it is the way out when there is no one to pay. + CodeButton( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x2), + text = stringResource(R.string.action_orGiveAsCash), + buttonState = ButtonState.Filled10, + onClick = onGiveAsCash, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + SearchInput( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(vertical = CodeTheme.dimens.grid.x2), + state = query, + ) + LazyColumn(contentPadding = PaddingValues(bottom = CodeTheme.dimens.grid.x4)) { + items(recipients.orEmpty(), key = { it.chatId.toString() }) { chat -> + RecipientRow(chat = chat, onClick = { onPick(chat) }) + } + } + } + } +} + +@Composable +private fun RecipientRow(chat: ConversationReference, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = CodeTheme.dimens.inset), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + ContactAvatar( + image = chat.image, + displayName = chat.name.orEmpty(), + access = chat.avatarAccess, + modifier = Modifier + .requiredSize(CodeTheme.dimens.staticGrid.x6) + .clip(CircleShape), + ) + Column(modifier = Modifier.weight(1f)) { + Column(modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x3)) { + Text( + text = chat.name.orEmpty(), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + chat.handle?.let { + Text( + text = "@$it", + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + HorizontalDivider(color = CodeTheme.colors.divider) + } + } +} + +/** + * The confirm sheet over Select Recipient: who is being paid, the amount, the currency with no + * picker, and the swipe. Closed by back, never by a drag or a tap outside it, and not mid-send. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SwipeToSendSheet( + recipient: ConversationReference, + amountText: String, + token: com.getcode.opencode.model.financial.Token?, + sendProgress: com.getcode.view.LoadingSuccessState, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val locked by rememberUpdatedState(!sendProgress.isIdle) + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { !locked }, + ) + ModalBottomSheet( + onDismissRequest = { if (!locked) onDismiss() }, + sheetState = sheetState, + sheetGesturesEnabled = false, + dragHandle = null, + properties = ModalBottomSheetProperties(shouldDismissOnClickOutside = false), + containerColor = CodeTheme.colors.background, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x6, bottom = CodeTheme.dimens.grid.x4), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + ContactAvatar( + image = recipient.image, + displayName = recipient.name.orEmpty(), + access = recipient.avatarAccess, + modifier = Modifier + .requiredSize(CodeTheme.dimens.staticGrid.x12) + .clip(CircleShape), + ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = recipient.name.orEmpty(), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + recipient.handle?.let { + Text( + text = "@$it", + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + } + Text( + text = amountText, + style = CodeTheme.typography.displayMedium, + color = CodeTheme.colors.textMain, + ) + TokenSelectionPill( + token = token, + background = White10, + textStyle = CodeTheme.typography.textMedium, + imageSize = CodeTheme.dimens.staticGrid.x3, + contentPadding = PaddingValues( + horizontal = CodeTheme.dimens.grid.x1 + 1.dp, + vertical = CodeTheme.dimens.grid.x1 - 1.dp, + ), + showsPicker = false, + onClick = {}, + ) + SlideToConfirm( + onConfirm = onConfirm, + modifier = Modifier + .fillMaxWidth() + .padding(top = CodeTheme.dimens.grid.x3), + enabled = sendProgress.isIdle, + isLoading = sendProgress.loading, + isSuccess = sendProgress.success, + label = stringResource(R.string.action_swipeToSend), + ) + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendRecipients.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendRecipients.kt new file mode 100644 index 0000000000..b761566005 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendRecipients.kt @@ -0,0 +1,26 @@ +package com.flipcash.app.messenger.internal.currencysend + +import com.flipcash.services.models.chat.ChatType +import com.flipcash.shared.chat.ui.ConversationReference + +/** + * Who Select Recipient lists: the viewer's 1:1 chats with another person, in the chat list's order + * (newest activity first, ties keeping their input order). Groups never appear, and neither does + * a chat with no resolved counterparty, since there is no one to pay. + * + * Only tip DMs are on the Chats list, so they are the recipients; a contact DM is addressed by + * phone number and has its own inbox. + */ +internal fun List.currencySendRecipients(): List = + filter { !it.isGroup && it.chatType == ChatType.TIP_DM && it.userId != null } + .sortedByDescending { it.lastActivity } + +/** The search field: a case-insensitive match on name or username, with a leading `@` ignored. */ +internal fun List.matching(query: String): List { + val needle = query.trim().removePrefix("@").trim() + if (needle.isEmpty()) return this + return filter { chat -> + chat.displayName.orEmpty().contains(needle, ignoreCase = true) || + chat.handle.orEmpty().contains(needle, ignoreCase = true) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt new file mode 100644 index 0000000000..ecb1803b24 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt @@ -0,0 +1,248 @@ +package com.flipcash.app.messenger.internal.currencysend + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.TransferEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.features.messenger.R +import com.flipcash.services.models.TipAction +import com.flipcash.services.models.TipOrigin +import com.flipcash.services.user.UserManager +import com.flipcash.shared.amountentry.AmountEntryDelegate +import com.flipcash.shared.amountentry.AmountEntryLabel +import com.flipcash.shared.amountentry.AmountEntryStyle +import com.flipcash.app.core.ui.ConfirmationStyle +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.chatListFeed +import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.shared.chat.ui.toConversationReference +import com.flipcash.shared.payments.TipPaymentDelegate +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiatCalculator +import com.getcode.opencode.model.core.errors.ComputeVerifiedFiatError +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.SendLimit +import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.Mint +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.LoadingSuccessState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.math.min +import kotlin.time.Duration.Companion.milliseconds + +/** + * Send from Currency Info: an amount of one fixed currency, then the person to pay. + * + * The payment is the DM send's own: the same [VerifiedFiatCalculator] pricing and the same + * [TipPaymentDelegate.send] a tip DM's composer ends in, so what lands in that DM is the cash + * message a send from inside it would have produced. Nothing here opens the DM. + */ +@HiltViewModel +internal class CurrencySendViewModel @Inject constructor( + private val exchange: Exchange, + private val tokenCoordinator: TokenCoordinator, + private val transactionController: TransactionController, + private val chatCoordinator: ChatCoordinator, + private val userManager: UserManager, + private val resources: ResourceHelper, + private val verifiedFiatCalculator: VerifiedFiatCalculator, + private val tipPaymentDelegate: TipPaymentDelegate, + private val analytics: FlipcashAnalytics, +) : ViewModel() { + + enum class Stage { Amount, Recipients } + + data class State( + val token: Token? = null, + val stage: Stage = Stage.Amount, + /** The amount typed on the first step, in the viewer's currency. */ + val amount: Fiat? = null, + /** Null until the feed first emits, so an empty list means there is no one to offer. */ + val recipients: List? = null, + val query: String = "", + /** The person whose swipe sheet is up. */ + val selected: ConversationReference? = null, + val sendProgress: LoadingSuccessState = LoadingSuccessState(), + ) { + val visibleRecipients: List? + get() = recipients?.matching(query) + } + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + private val mint = MutableStateFlow(null) + + private val _done = Channel(Channel.BUFFERED) + + /** Fires once the checkmark has held, which is where the whole flow goes back to Currency Info. */ + val done: Flow = _done.receiveAsFlow() + + private val maxAmount: StateFlow = mint.filterNotNull() + .flatMapLatest { mint -> + combine( + transactionController.limits, + tokenCoordinator.balanceForToken(mint), + exchange.observePreferredRate(), + ) { limits, balance, rate -> + val balanceInLocal = balance.convertingTo(rate) + val sendLimit = limits?.sendLimitFor(rate.currency) ?: SendLimit.Zero + Fiat(min(sendLimit.nextTransaction, balanceInLocal.toDouble()), rate.currency) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + val amountDelegate = AmountEntryDelegate( + exchange = exchange, + scope = viewModelScope, + style = AmountEntryStyle( + actionLabel = AmountEntryLabel.Plain(resources.getString(R.string.action_next)), + actionStyle = ConfirmationStyle.Button, + infoHint = { resources.getString(R.string.subtitle_sendHint, it) }, + overMaxHint = { resources.getString(R.string.subtitle_sendHintLimitExceeded, it) }, + standingHint = AmountEntryStyle.StandingHint.Ceiling, + ), + loadingState = MutableStateFlow(LoadingSuccessState()), + maxAmount = maxAmount, + ) + + init { + viewModelScope.launch { + val selfId = userManager.accountId + val chats = chatCoordinator.chatListFeed().first() + .map { it.toConversationReference(selfId, emptyMap(), resources, exchange = exchange) } + _state.update { it.copy(recipients = chats.currencySendRecipients()) } + } + exchange.observePreferredRate() + .onEachCurrency() + } + + private fun Flow.onEachCurrency() { + viewModelScope.launch { + collect { rate -> + exchange.getCurrency(rate.currency.name)?.let(amountDelegate::onCurrencyChanged) + } + } + } + + /** Pins the flow to [mint]. Called once; the currency never changes afterwards. */ + fun start(mint: Mint) { + if (this.mint.value != null) return + this.mint.value = mint + _state.update { it.copy(token = tokenCoordinator.cachedToken(mint)) } + } + + /** Next on the amount step: the typed amount stands, and Select Recipient comes up. */ + fun onAmountConfirmed() { + val entered = amountDelegate.state.value.enteredAmount + if (entered <= 0) return + _state.update { + it.copy( + amount = Fiat(entered, exchange.preferredRate.currency), + stage = Stage.Recipients, + ) + } + } + + /** Back from Select Recipient to the amount, which keeps what was typed. */ + fun onBackToAmount() { + _state.update { it.copy(stage = Stage.Amount, query = "", selected = null) } + } + + fun onQueryChanged(query: String) = _state.update { it.copy(query = query) } + + fun onRecipientTapped(chat: ConversationReference) { + if (!_state.value.sendProgress.isIdle) return + _state.update { it.copy(selected = chat) } + } + + /** Closes the swipe sheet, back to the list. A send in flight can't be backed out of. */ + fun onConfirmDismissed() { + if (!_state.value.sendProgress.isIdle) return + _state.update { it.copy(selected = null) } + } + + fun send() { + val current = _state.value + val recipient = current.selected ?: return + val userId = recipient.userId ?: return + val token = current.token ?: return + val amount = current.amount ?: return + if (!current.sendProgress.isIdle) return + + viewModelScope.launch { + val owner = userManager.accountCluster ?: return@launch + val rate = exchange.preferredRate + _state.update { it.copy(sendProgress = LoadingSuccessState(loading = true)) } + + val verifiedFiat = verifiedFiatCalculator.compute( + amount = amount, + token = token, + balance = tokenCoordinator.balanceForToken(token), + rate = rate, + ).getOrElse { error -> + _state.update { it.copy(sendProgress = LoadingSuccessState()) } + val (title, message) = when (error) { + is ComputeVerifiedFiatError.AmountBelowMinimum -> + R.string.error_title_amountTooSmall to R.string.error_description_amountTooSmall + else -> R.string.error_title_staleRates to R.string.error_description_staleRates + } + BottomBarManager.showAlert( + title = resources.getString(title), + message = resources.getString(message), + ) + return@launch + } + + tipPaymentDelegate.send( + userId = userId, + verifiedFiat = verifiedFiat, + token = token, + source = owner.withTimelockForToken(token), + origin = TipOrigin.CHAT, + action = TipAction.SEND, + ).onSuccess { + _state.update { it.copy(sendProgress = LoadingSuccessState(success = true)) } + delay(400.milliseconds) + analytics.track( + TransferEvents.sentCash(AnalyticsState.SUCCESS, verifiedFiat.localFiat.analytics, null) + ) + _done.trySend(Unit) + }.onFailure { cause -> + _state.update { it.copy(sendProgress = LoadingSuccessState()) } + analytics.track( + TransferEvents.sentCash( + AnalyticsState.FAILURE, + verifiedFiat.localFiat.analytics, + cause.analytics, + ) + ) + BottomBarManager.showError( + title = resources.getString(R.string.error_title_cashFailedToSend), + message = resources.getString(R.string.error_description_cashFailedToSend), + ) + } + } + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendRecipientsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendRecipientsTest.kt new file mode 100644 index 0000000000..03f18c7141 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendRecipientsTest.kt @@ -0,0 +1,67 @@ +package com.flipcash.app.messenger.internal.currencysend + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType +import com.flipcash.shared.chat.ui.ConversationReference +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +class CurrencySendRecipientsTest { + private var next = 0 + + private fun chat( + name: String?, + handle: String? = null, + type: ChatType = ChatType.TIP_DM, + activity: Long = 0, + withUser: Boolean = true, + ) = ConversationReference( + chatId = ChatId(ByteArray(32) { (next + 1).toByte() }.also { next++ }), + userId = if (withUser && type != ChatType.GROUP) ByteArray(32) { 1 }.toList() else null, + displayName = name, + handle = handle, + isGroup = type == ChatType.GROUP, + chatType = type, + lastActivity = Instant.fromEpochSeconds(activity), + ) + + @Test + fun `groups and contact dms never appear`() { + val result = listOf( + chat("Noah", activity = 3), + chat(null, type = ChatType.GROUP, activity = 9), + chat("Contact", type = ChatType.CONTACT_DM, activity = 8), + ).currencySendRecipients() + assertEquals(listOf("Noah"), result.map { it.displayName }) + } + + @Test + fun `a chat with no counterparty is dropped`() { + val result = listOf(chat("Ghost", withUser = false), chat("Real")).currencySendRecipients() + assertEquals(listOf("Real"), result.map { it.displayName }) + } + + @Test + fun `newest activity first and ties keep input order`() { + val result = listOf( + chat("Old", activity = 1), + chat("TieA", activity = 5), + chat("New", activity = 9), + chat("TieB", activity = 5), + ).currencySendRecipients() + assertEquals(listOf("New", "TieA", "TieB", "Old"), result.map { it.displayName }) + } + + @Test + fun `search matches name or username and ignores a leading at sign`() { + val list = listOf( + chat("Noah Parker", handle = "noahp"), + chat("Chloe Anderson", handle = "chloe"), + ) + assertEquals(listOf("Noah Parker"), list.matching("noah").map { it.displayName }) + assertEquals(listOf("Chloe Anderson"), list.matching("@CHL").map { it.displayName }) + assertEquals(2, list.matching(" ").size) + assertEquals(0, list.matching("zzz").size) + } +} From b26634cea9d3f32a102280014823870a02de5636 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 17:37:24 -0400 Subject: [PATCH 06/30] feat(tokens): Send, Transactions and More tiles, top community chats on Currency Info --- .../ui/navigation/AppScreenContent.kt | 2 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 3 + .../flipcash/features/tokens/build.gradle.kts | 1 + .../tokens/internal/CommunityChatsScreen.kt | 70 +++++++ .../components/info/CommunityChatsSection.kt | 117 +++++++++++ .../components/info/CurrencyInfoContentV2.kt | 187 +++++++----------- .../app/tokens/ui/CommunityChatsViewModel.kt | 41 ++++ .../flipcash/app/tokens/ui/CurrencyActions.kt | 33 ++++ .../app/tokens/ui/TokenInfoViewModel.kt | 27 ++- .../app/tokens/ui/CurrencyActionsTest.kt | 42 ++++ 10 files changed, 396 insertions(+), 127 deletions(-) create mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt create mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt create mode 100644 apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChatsViewModel.kt create mode 100644 apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CurrencyActions.kt create mode 100644 apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CurrencyActionsTest.kt 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 64cdc93195..c63d4077b3 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 @@ -44,6 +44,7 @@ import com.flipcash.app.currency.RegionSelectionScreen import com.flipcash.app.deposit.DepositFlowScreen import com.flipcash.app.invite.InviteContactScreen import com.flipcash.app.messenger.ChatFlowScreen +import com.flipcash.app.tokens.internal.CommunityChatsScreen import com.flipcash.app.messenger.CurrencySendScreen import com.flipcash.app.messenger.E2eeLearnMoreScreen import com.flipcash.app.messenger.MuteChatScreen @@ -187,6 +188,7 @@ internal fun appEntryProvider( annotatedEntry(testTag = "transaction_history_screen") { key -> TransactionHistoryScreen(key.mint) } + annotatedEntry(testTag = "community_chats_screen") { key -> CommunityChatsScreen(key.mint) } annotatedEntry(testTag = "market_cap_explainer_screen") { key -> MarketCapExplainerScreen(key.mint) } 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 38c155fdc0..49d3b91398 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 @@ -245,6 +245,9 @@ sealed interface AppRoute : NavKey, Parcelable { data class Transactions(val mint: Mint) : Token @Serializable data class MarketCapExplainer(val mint: Mint) : Token + /** "Top Community Chats": View All from Currency Info. */ + @Serializable + data class CommunityChats(val mint: Mint) : Token @Serializable data class Swap( val purpose: SwapPurpose, diff --git a/apps/flipcash/features/tokens/build.gradle.kts b/apps/flipcash/features/tokens/build.gradle.kts index e5739b5cb4..86a8aeb45c 100644 --- a/apps/flipcash/features/tokens/build.gradle.kts +++ b/apps/flipcash/features/tokens/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { implementation(project(":apps:flipcash:shared:onramp:deeplinks")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:shareable")) + implementation(project(":apps:flipcash:shared:common-ui")) implementation(project(":apps:flipcash:shared:tokens")) implementation(project(":apps:flipcash:shared:transaction-history")) implementation(libs.bundles.haze) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt new file mode 100644 index 0000000000..ab51d0a3b3 --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt @@ -0,0 +1,70 @@ +package com.flipcash.app.tokens.internal + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.tokens.internal.components.info.CommunityChatRow +import com.flipcash.app.tokens.ui.CommunityChatsViewModel +import com.flipcash.features.tokens.R +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.solana.keys.Mint +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeScaffold + +/** "Top Community Chats", View All's destination: every chat the server ranked for [mint]. */ +@Composable +fun CommunityChatsScreen(mint: Mint) { + val viewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + val navigator = LocalCodeNavigator.current + + LaunchedEffect(viewModel, mint) { viewModel.load(mint) } + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_topCommunityChats), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { navigator.pop() }, + ) + }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.grid.x2, + ), + ) { + items(state.chats, key = { it.chatId.toString() }) { chat -> + CommunityChatRow( + chat = chat, + currencyName = state.currencyName, + onClick = { + navigator.push( + AppRoute.Messaging.Chat( + identifier = ChatIdentifier.ByChatId(chat.chatId), + openOnProfile = true, + ) + ) + }, + ) + } + } + } +} diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt new file mode 100644 index 0000000000..b4f580c09e --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt @@ -0,0 +1,117 @@ +package com.flipcash.app.tokens.internal.components.info + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info +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.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.flipcash.app.tokens.ui.communityHoldings +import com.flipcash.features.tokens.R +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.theme.CodeTheme + +/** "Top Community Chats", its info button and View All, then the chats given. */ +@Composable +internal fun CommunityChatsHeader( + onInfo: () -> Unit, + onViewAll: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.title_topCommunityChats), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = stringResource(R.string.title_howChatsAreRanked), + tint = CodeTheme.colors.textSecondary, + modifier = Modifier + .padding(start = CodeTheme.dimens.grid.x1) + .requiredSize(CodeTheme.dimens.staticGrid.x4) + .clip(CircleShape) + .clickable(onClick = onInfo), + ) + Text( + modifier = Modifier + .weight(1f) + .clickable(onClick = onViewAll), + text = stringResource(R.string.action_viewAll), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = androidx.compose.ui.text.style.TextAlign.End, + ) + } +} + +@Composable +internal fun CommunityChatRow( + chat: ChatMetadata, + currencyName: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val holdings = chat.communityHoldings() + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + ContactAvatar( + image = chat.picture, + displayName = chat.title.orEmpty(), + access = BlobAccessContext.ChatProfile(chat.chatId), + modifier = Modifier + .requiredSize(CodeTheme.dimens.staticGrid.x10) + .clip(CircleShape), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = chat.title.orEmpty(), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (holdings != null) { + Text( + text = pluralStringResource( + R.plurals.subtitle_communityChatHoldings, + holdings.memberCount.toInt(), + holdings.amount, + currencyName, + holdings.memberCount.toInt(), + ), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} 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 a22a5527c8..3ccad1f8ce 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 @@ -58,7 +58,10 @@ import com.flipcash.app.core.ui.transitions.sharedBoundsTransition import com.getcode.opencode.model.financial.Token import com.flipcash.app.tokens.ui.TokenInfoViewModel import com.flipcash.features.tokens.R -import com.flipcash.shared.transactionhistory.recentActivitySection +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.tokens.ui.CurrencyActionTile +import com.flipcash.app.tokens.ui.TopCommunityChatsPreviewCount +import com.flipcash.app.tokens.ui.currencyActionTiles import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.SocialLink @@ -225,34 +228,47 @@ internal fun CurrencyInfoContentV2( isHeld = isHeld, tokenMint = token.address, shortfall = shortfall, - convertEnabled = state.canConvert, dispatch = dispatch, ) } - // 3. Recent transactions (only when held and non-empty) — shared with the wallet screen. - if (isHeld && state.transactions.isNotEmpty()) { - recentActivitySection( - transactions = state.transactions, - modifier = Modifier - .clickable { + // 3. Top community chats (hidden when empty or the call failed) + if (state.communityChats.isNotEmpty()) { + item { + CommunityChatsHeader( + modifier = Modifier + .fillParentMaxWidth() + .padding(horizontal = inset) + .padding(top = grid.x5), + onInfo = { dispatch(TokenInfoViewModel.Event.OnRankingInfo) }, + onViewAll = { dispatch( TokenInfoViewModel.Event.OpenScreen( - AppRoute.Token.Transactions(token.address) + AppRoute.Token.CommunityChats(token.address) ) ) - } - .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) + }, + ) + } + items(state.communityChats.take(TopCommunityChatsPreviewCount), key = { it.chatId.toString() }) { chat -> + CommunityChatRow( + modifier = Modifier + .fillParentMaxWidth() + .padding(horizontal = inset), + chat = chat, + currencyName = token.name, + onClick = { + dispatch( + TokenInfoViewModel.Event.OpenScreen( + AppRoute.Messaging.Chat( + identifier = ChatIdentifier.ByChatId(chat.chatId), + openOnProfile = true, + ) + ) ) - ) - }, - ) + }, + ) + } } // 4. Market cap (non-USDF only) @@ -350,117 +366,46 @@ private fun CurrencyActionTiles( isHeld: Boolean, tokenMint: Mint, shortfall: Fiat?, - convertEnabled: Boolean, dispatch: (TokenInfoViewModel.Event) -> Unit, modifier: Modifier = Modifier, ) { - val giveTile: @Composable RowScope.() -> Unit = { - ActionTile( - modifier = Modifier.weight(1f), - label = stringResource(R.string.action_cash), - icon = { - Icon( - painter = painterResource(R.drawable.ic_banknote), - contentDescription = null, - tint = CodeTheme.colors.textMain, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), - ) - }, - onClick = { - dispatch( - TokenInfoViewModel.Event.OpenScreen( - AppRoute.Sheets.Give(mint = tokenMint, fromTokenInfo = true) - ) - ) - }, - ) - } - - val convertTile: @Composable RowScope.() -> Unit = { - ActionTile( - modifier = Modifier.weight(1f), - label = stringResource(R.string.action_convert), - icon = { - Icon( - painter = painterResource(R.drawable.ic_convert), - contentDescription = null, - tint = CodeTheme.colors.textMain, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), - ) - }, - onClick = { dispatch(TokenInfoViewModel.Event.OnConvert) }, - enabled = convertEnabled, - ) - } - Row( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { - when { - !isHeld -> { - // Single full-width "Buy In" tile - ActionTile( - modifier = Modifier.weight(1f), - label = stringResource(R.string.action_buyIn), - icon = { - Icon( - painter = painterResource(R.drawable.ic_arrow_down), - contentDescription = null, - tint = CodeTheme.colors.textMain, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), - ) - }, - onClick = { dispatch(TokenInfoViewModel.Event.OnBuy(shortfall)) }, - ) + currencyActionTiles(tokenMint, isHeld).forEach { tile -> + val (label, icon) = when (tile) { + CurrencyActionTile.Send -> R.string.action_send to R.drawable.ic_arrow_up_large + CurrencyActionTile.Buy -> R.string.action_buy to R.drawable.ic_arrow_down + CurrencyActionTile.BuyIn -> R.string.action_buyIn to R.drawable.ic_arrow_down + CurrencyActionTile.Transactions -> R.string.action_transactions to R.drawable.ic_transactions + CurrencyActionTile.More -> R.string.action_more to R.drawable.ic_more_horizontal } - - // Dollars keeps Withdraw: there is no "buy more" of the cash reserve, and cashing - // out is the action people come to this screen for. - tokenMint == Mint.usdf -> { - giveTile() - convertTile() - ActionTile( - modifier = Modifier.weight(1f), - label = stringResource(R.string.action_withdraw), - icon = { - Icon( - painter = painterResource(R.drawable.ic_arrow_up_large), - contentDescription = null, - tint = CodeTheme.colors.textMain, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), - ) - }, - onClick = { - // Preselect the currency being viewed: Dollars detours through the - // "Withdraw as USDC" intro, anything else opens straight on the amount screen. - dispatch( - TokenInfoViewModel.Event.OpenScreen( - AppRoute.Transfers.Withdrawal(preselectedMint = tokenMint) - ) + ActionTile( + modifier = Modifier.weight(1f), + label = stringResource(label), + icon = { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), + ) + }, + onClick = { + when (tile) { + CurrencyActionTile.Send -> dispatch( + TokenInfoViewModel.Event.OpenScreen(AppRoute.Sheets.CurrencySend(tokenMint)) ) - }, - ) - } - - // Held non-USDF token: Give + Buy More + Convert - else -> { - giveTile() - ActionTile( - modifier = Modifier.weight(1f), - label = stringResource(R.string.action_buyMore), - icon = { - Icon( - painter = painterResource(R.drawable.ic_arrow_down), - contentDescription = null, - tint = CodeTheme.colors.textMain, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), + CurrencyActionTile.Buy, CurrencyActionTile.BuyIn -> + dispatch(TokenInfoViewModel.Event.OnBuy(shortfall)) + CurrencyActionTile.Transactions -> dispatch( + TokenInfoViewModel.Event.OpenScreen(AppRoute.Token.Transactions(tokenMint)) ) - }, - onClick = { dispatch(TokenInfoViewModel.Event.OnBuy(shortfall)) }, - ) - convertTile() - } + CurrencyActionTile.More -> dispatch(TokenInfoViewModel.Event.OnMore) + } + }, + ) } } } diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChatsViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChatsViewModel.kt new file mode 100644 index 0000000000..8e8b1f53a1 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChatsViewModel.kt @@ -0,0 +1,41 @@ +package com.flipcash.app.tokens.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.controllers.CommunityController +import com.flipcash.services.models.chat.ChatMetadata +import com.getcode.solana.keys.Mint +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Every chat `GetCommunity` ranks for one currency, for the View All screen. */ +@HiltViewModel +class CommunityChatsViewModel @Inject constructor( + private val communityController: CommunityController, + private val tokenCoordinator: TokenCoordinator, +) : ViewModel() { + data class State( + val currencyName: String = "", + val chats: List = emptyList(), + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + private var loaded: Mint? = null + + fun load(mint: Mint) { + if (loaded == mint) return + loaded = mint + _state.update { it.copy(currencyName = tokenCoordinator.cachedToken(mint)?.name.orEmpty()) } + viewModelScope.launch { + val chats = communityController.getCommunity(mint).topCommunityChats() + _state.update { it.copy(chats = chats) } + } + } +} diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CurrencyActions.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CurrencyActions.kt new file mode 100644 index 0000000000..c69343b1ca --- /dev/null +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CurrencyActions.kt @@ -0,0 +1,33 @@ +package com.flipcash.app.tokens.ui + +import com.getcode.solana.keys.Mint + +/** A tile in Currency Info's action row. */ +enum class CurrencyActionTile { Send, Buy, BuyIn, Transactions, More } + +/** + * The action row for a currency: a held currency sends, buys more (not Dollars, which has no + * buy), lists its transactions and opens More. One that isn't held offers only Buy In. + */ +fun currencyActionTiles(mint: Mint, isHeld: Boolean): List = when { + !isHeld -> listOf(CurrencyActionTile.BuyIn) + mint == Mint.usdf -> listOf( + CurrencyActionTile.Send, + CurrencyActionTile.Transactions, + CurrencyActionTile.More, + ) + else -> listOf( + CurrencyActionTile.Send, + CurrencyActionTile.Buy, + CurrencyActionTile.Transactions, + CurrencyActionTile.More, + ) +} + +/** A row in the More sheet. */ +enum class MoreRow { Convert, Withdraw, Deposit } + +/** Dollars has no Deposit row: its deposit is the account's own, not a per-currency one. */ +fun moreRows(mint: Mint): List = + if (mint == Mint.usdf) listOf(MoreRow.Convert, MoreRow.Withdraw) + else listOf(MoreRow.Convert, MoreRow.Withdraw, MoreRow.Deposit) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt index 3c016e5504..da59f453f4 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt @@ -128,6 +128,7 @@ class TokenInfoViewModel @Inject constructor( data class OnBuy(val shortFall: Fiat? = null) : Event data object OnConvert : Event data object OnMore : Event + data object OnRankingInfo : Event data class OnCommunityChatsLoaded(val chats: List) : Event data object PresentDepositOptions: Event data class OpenScreen(val screen: AppRoute) : Event @@ -411,8 +412,24 @@ class TokenInfoViewModel @Inject constructor( eventFlow .filterIsInstance() .mapNotNull { stateFlow.value.token.dataOrNull } - .map { Shareable.TokenInfo(it) } - .onEach { shareController.present(it) } + .onEach { token -> + // The sheet offers the system share, copy, and recent chats; the system share + // sheet it opens is `shareController` with this same shareable. + dispatchEvent( + Event.OpenScreen(AppRoute.Messaging.ShareCurrencyToChats(token.address, token.name)) + ) + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + val name = stateFlow.value.token.dataOrNull?.name ?: return@onEach + BottomBarManager.showInfo( + title = resources.getString(R.string.title_howChatsAreRanked), + message = resources.getString(R.string.description_howChatsAreRanked, name), + ) + } .launchIn(viewModelScope) } @@ -424,8 +441,7 @@ class TokenInfoViewModel @Inject constructor( val state = stateFlow.value val mint = state.mint ?: return val name = state.token.dataOrNull?.name ?: return - val rows = if (mint == Mint.usdf) listOf(MoreRow.Convert, MoreRow.Withdraw) - else listOf(MoreRow.Convert, MoreRow.Withdraw, MoreRow.Deposit) + val rows = moreRows(mint) BottomBarManager.showMessage( title = "", actions = rows.map { row -> @@ -461,8 +477,6 @@ class TokenInfoViewModel @Inject constructor( ) } - private enum class MoreRow { Convert, Withdraw, Deposit } - companion object { /** Rows shown in the token info screen's per-token recent-activity preview. */ private const val RECENT_PREVIEW_COUNT = 3 @@ -506,6 +520,7 @@ class TokenInfoViewModel @Inject constructor( Event.OnConvert -> { state -> state } is Event.LoadHistoricalDataForPeriod -> { state -> state } is Event.Share -> { state -> state } + Event.OnRankingInfo -> { state -> state } is Event.Exit -> { state -> state } } } diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CurrencyActionsTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CurrencyActionsTest.kt new file mode 100644 index 0000000000..16f7446cf1 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CurrencyActionsTest.kt @@ -0,0 +1,42 @@ +package com.flipcash.app.tokens.ui + +import com.getcode.solana.keys.Mint +import kotlin.test.Test +import kotlin.test.assertEquals + +class CurrencyActionsTest { + private val jeffy = Mint(List(32) { 7.toByte() }) + + @Test + fun `held currency sends, buys, lists transactions and opens more`() { + assertEquals( + listOf( + CurrencyActionTile.Send, + CurrencyActionTile.Buy, + CurrencyActionTile.Transactions, + CurrencyActionTile.More, + ), + currencyActionTiles(jeffy, isHeld = true), + ) + } + + @Test + fun `dollars has no buy tile`() { + assertEquals( + listOf(CurrencyActionTile.Send, CurrencyActionTile.Transactions, CurrencyActionTile.More), + currencyActionTiles(Mint.usdf, isHeld = true), + ) + } + + @Test + fun `a currency that is not held offers only buy in`() { + assertEquals(listOf(CurrencyActionTile.BuyIn), currencyActionTiles(jeffy, isHeld = false)) + assertEquals(listOf(CurrencyActionTile.BuyIn), currencyActionTiles(Mint.usdf, isHeld = false)) + } + + @Test + fun `more lists convert, withdraw and deposit, and dollars has no deposit`() { + assertEquals(listOf(MoreRow.Convert, MoreRow.Withdraw, MoreRow.Deposit), moreRows(jeffy)) + assertEquals(listOf(MoreRow.Convert, MoreRow.Withdraw), moreRows(Mint.usdf)) + } +} From 59d9585f2ca95fe8e4c1c53103519dd226206e17 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 17:42:49 -0400 Subject: [PATCH 07/30] fix(messenger): drop handle line from send recipient rows, avoid double @ --- .../com/flipcash/app/messenger/CurrencySendScreen.kt | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt index 5c4f0532d0..dd3d94abb5 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt @@ -222,15 +222,6 @@ private fun RecipientRow(chat: ConversationReference, onClick: () -> Unit) { maxLines = 1, overflow = TextOverflow.Ellipsis, ) - chat.handle?.let { - Text( - text = "@$it", - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } } HorizontalDivider(color = CodeTheme.colors.divider) } @@ -290,7 +281,7 @@ private fun SwipeToSendSheet( ) recipient.handle?.let { Text( - text = "@$it", + text = "@${it.removePrefix("@")}", style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, ) From 15ed514175a9773ff0ee1fcb197397383e4bb0ae Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 17:56:01 -0400 Subject: [PATCH 08/30] fix(tokens): show community chat holdings as fiat, keep member count without holdings --- .../core/src/main/res/values/strings.xml | 4 ++ .../tokens/internal/CommunityChatsScreen.kt | 7 +- .../components/info/CommunityChatsSection.kt | 30 +++++---- .../components/info/CurrencyInfoContentV2.kt | 7 +- .../flipcash/app/tokens/ui/CommunityChats.kt | 64 ++++++++++--------- .../app/tokens/ui/CommunityChatsViewModel.kt | 10 ++- .../app/tokens/ui/TokenInfoViewModel.kt | 15 +++-- .../app/tokens/ui/CommunityChatsTest.kt | 50 +++++++++------ 8 files changed, 109 insertions(+), 78 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 57c0994e35..98b3f2336b 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1873,6 +1873,10 @@ %1$s of %2$s held by %3$d member %1$s of %2$s held by %3$d members + + %1$d member + %1$d members + Select Recipient Share %1$s Recent Chats diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt index ab51d0a3b3..f1136b3313 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt @@ -51,14 +51,15 @@ fun CommunityChatsScreen(mint: Mint) { vertical = CodeTheme.dimens.grid.x2, ), ) { - items(state.chats, key = { it.chatId.toString() }) { chat -> + items(state.chats, key = { it.chat.chatId.toString() }) { entry -> CommunityChatRow( - chat = chat, + chat = entry.chat, + holdings = entry.holdings, currencyName = state.currencyName, onClick = { navigator.push( AppRoute.Messaging.Chat( - identifier = ChatIdentifier.ByChatId(chat.chatId), + identifier = ChatIdentifier.ByChatId(entry.chat.chatId), openOnProfile = true, ) ) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt index b4f580c09e..afb2a691da 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.flipcash.app.tokens.ui.communityHoldings +import com.flipcash.app.tokens.ui.CommunityChatHoldings import com.flipcash.features.tokens.R import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatMetadata @@ -68,11 +68,11 @@ internal fun CommunityChatsHeader( @Composable internal fun CommunityChatRow( chat: ChatMetadata, + holdings: CommunityChatHoldings, currencyName: String, onClick: () -> Unit, modifier: Modifier = Modifier, ) { - val holdings = chat.communityHoldings() Row( modifier = modifier .fillMaxWidth() @@ -97,21 +97,25 @@ internal fun CommunityChatRow( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - if (holdings != null) { - Text( - text = pluralStringResource( + Text( + text = holdings.amount?.let { + pluralStringResource( R.plurals.subtitle_communityChatHoldings, holdings.memberCount.toInt(), - holdings.amount, + it, currencyName, holdings.memberCount.toInt(), - ), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + ) + } ?: pluralStringResource( + R.plurals.subtitle_communityChatMembers, + holdings.memberCount.toInt(), + holdings.memberCount.toInt(), + ), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } } 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 3ccad1f8ce..beefb09d15 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 @@ -250,18 +250,19 @@ internal fun CurrencyInfoContentV2( }, ) } - items(state.communityChats.take(TopCommunityChatsPreviewCount), key = { it.chatId.toString() }) { chat -> + items(state.communityChats.take(TopCommunityChatsPreviewCount), key = { it.chat.chatId.toString() }) { entry -> CommunityChatRow( modifier = Modifier .fillParentMaxWidth() .padding(horizontal = inset), - chat = chat, + chat = entry.chat, + holdings = entry.holdings, currencyName = token.name, onClick = { dispatch( TokenInfoViewModel.Event.OpenScreen( AppRoute.Messaging.Chat( - identifier = ChatIdentifier.ByChatId(chat.chatId), + identifier = ChatIdentifier.ByChatId(entry.chat.chatId), openOnProfile = true, ) ) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChats.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChats.kt index 8494562c66..17100f022f 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChats.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChats.kt @@ -1,18 +1,16 @@ package com.flipcash.app.tokens.ui +import com.flipcash.app.core.ui.forViewer import com.flipcash.services.models.Community import com.flipcash.services.models.chat.ChatMetadata -import java.math.BigDecimal -import java.math.RoundingMode -import java.text.DecimalFormat -import java.text.DecimalFormatSymbols -import java.util.Locale +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.Token /** Chats Currency Info previews before "View All". */ const val TopCommunityChatsPreviewCount = 3 -private const val QuarksPerToken = 10_000_000_000L - /** * The chats a Currency Info page lists, in the server's ranking. A failed call and an empty list * both give an empty list, which hides the section. @@ -20,32 +18,36 @@ private const val QuarksPerToken = 10_000_000_000L fun Result.topCommunityChats(): List = getOrNull()?.topHolderChats.orEmpty() -/** The values behind a chat row's " of held by N members" line. */ -data class CommunityChatHoldings(val amount: String, val memberCount: Long) - /** - * Null when the server sent no holdings for the chat, so the row shows no subtitle rather than a - * made-up zero. + * What a chat row's " of held by N members" line shows: [amount] is the fiat value + * of the chat's holdings in the viewer's currency, or null when the line is just "N members". */ -fun ChatMetadata.communityHoldings(locale: Locale = Locale.getDefault()): CommunityChatHoldings? { - val summary = holdingsSummary ?: return null - return CommunityChatHoldings( - amount = formatQuarksCompact(summary.quarks, locale), - memberCount = rosterSummary.memberCount, - ) -} +data class CommunityChatHoldings(val amount: String?, val memberCount: Long) -/** A token amount from raw [quarks], abbreviated: 950, 1.2K, 3.4M, 5B. Rounds down. */ -fun formatQuarksCompact(quarks: Long, locale: Locale = Locale.getDefault()): String { - val tokens = BigDecimal(quarks).divide(BigDecimal(QuarksPerToken)) - val (scaled, suffix) = when { - tokens >= BigDecimal(1_000_000_000) -> tokens.movePointLeft(9) to "B" - tokens >= BigDecimal(1_000_000) -> tokens.movePointLeft(6) to "M" - tokens >= BigDecimal(1_000) -> tokens.movePointLeft(3) to "K" - else -> tokens to "" - } - val format = DecimalFormat("#,##0.#", DecimalFormatSymbols(locale)).apply { - roundingMode = RoundingMode.DOWN +/** + * The holdings quarks valued on the bonding curve at [token]'s current supply, then shown in the + * viewer's currency like the hero balance. Null amount when the server sent no holdings or [token] + * is not known, matching iOS, which still shows the member count. + */ +fun ChatMetadata.communityHoldings( + token: Token?, + preferredRate: Rate, + rates: Map, +): CommunityChatHoldings { + val amount = holdingsSummary?.let { summary -> + token ?: return@let null + runCatching { Fiat.tokenBalance(summary.quarks, token) }.getOrNull() + ?.forViewer(preferredRate, rates) + ?.formatted(Fiat.FormattingRule.Truncated) } - return format.format(scaled) + suffix + return CommunityChatHoldings(amount = amount, memberCount = rosterSummary.memberCount) } + +/** A ranked chat with the line its row shows. */ +data class CommunityChatEntry(val chat: ChatMetadata, val holdings: CommunityChatHoldings) + +fun List.asEntries( + token: Token?, + preferredRate: Rate, + rates: Map, +): List = map { CommunityChatEntry(it, it.communityHoldings(token, preferredRate, rates)) } diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChatsViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChatsViewModel.kt index 8e8b1f53a1..a288f02a7f 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChatsViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChatsViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.controllers.CommunityController -import com.flipcash.services.models.chat.ChatMetadata +import com.getcode.opencode.exchange.Exchange import com.getcode.solana.keys.Mint import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -19,10 +19,11 @@ import javax.inject.Inject class CommunityChatsViewModel @Inject constructor( private val communityController: CommunityController, private val tokenCoordinator: TokenCoordinator, + private val exchange: Exchange, ) : ViewModel() { data class State( val currencyName: String = "", - val chats: List = emptyList(), + val chats: List = emptyList(), ) private val _state = MutableStateFlow(State()) @@ -35,7 +36,10 @@ class CommunityChatsViewModel @Inject constructor( _state.update { it.copy(currencyName = tokenCoordinator.cachedToken(mint)?.name.orEmpty()) } viewModelScope.launch { val chats = communityController.getCommunity(mint).topCommunityChats() - _state.update { it.copy(chats = chats) } + val token = tokenCoordinator.cachedToken(mint) + exchange.observePreferredRate().collect { rate -> + _state.update { it.copy(chats = chats.asEntries(token, rate, exchange.rates())) } + } } } } diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt index da59f453f4..8d1c86a321 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt @@ -44,6 +44,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.emitAll import javax.inject.Inject @HiltViewModel @@ -77,7 +78,7 @@ class TokenInfoViewModel @Inject constructor( /** Bounded per-token recent activity preview (newest first) for the v2 currency-info screen. */ val transactions: List = emptyList(), /** Public groups gated on this currency, ranked by the server. Empty hides the section. */ - val communityChats: List = emptyList(), + val communityChats: List = emptyList(), ) { /** True when the user holds a displayable balance in some token other than [mint], which could fund a buy of it. */ val hasFundableBalance: Boolean @@ -129,7 +130,7 @@ class TokenInfoViewModel @Inject constructor( data object OnConvert : Event data object OnMore : Event data object OnRankingInfo : Event - data class OnCommunityChatsLoaded(val chats: List) : Event + data class OnCommunityChatsLoaded(val chats: List) : Event data object PresentDepositOptions: Event data class OpenScreen(val screen: AppRoute) : Event data object Exit : Event @@ -395,9 +396,13 @@ class TokenInfoViewModel @Inject constructor( .flatMapLatest { mint -> kotlinx.coroutines.flow.flow { // USDF has no community; a failed call and an empty list both hide the section. - emit( - if (mint == Mint.usdf) emptyList() - else communityController.getCommunity(mint).topCommunityChats() + val chats = if (mint == Mint.usdf) emptyList() + else communityController.getCommunity(mint).topCommunityChats() + val token = tokenCoordinator.cachedToken(mint) + // Re-value when the viewer's currency or rates change. + emitAll( + exchange.observePreferredRate() + .map { rate -> chats.asEntries(token, rate, exchange.rates()) } ) } } diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CommunityChatsTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CommunityChatsTest.kt index 000b4b2a3f..162ed9c9f6 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CommunityChatsTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CommunityChatsTest.kt @@ -6,17 +6,16 @@ import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.HoldingsSummary import com.flipcash.services.models.chat.RosterSummary +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.usdf import com.getcode.solana.keys.Mint -import java.util.Locale import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.time.Instant class CommunityChatsTest { - private val us = Locale.US - private val quarksPerToken = 10_000_000_000L - private fun chat(holdings: HoldingsSummary?, members: Long = 14) = ChatMetadata( chatId = ChatId(ByteArray(32)), type = ChatType.GROUP, @@ -27,32 +26,43 @@ class CommunityChatsTest { holdingsSummary = holdings, ) + private val usd = Rate.oneToOne + private val usdf = Token.usdf + + private fun summary(quarks: Long) = HoldingsSummary( + mint = Mint.usdf, + quarks = quarks, + holderCount = 9, + asOf = Instant.fromEpochSeconds(0), + ) + @Test - fun `quarks format as abbreviated token amounts`() { - assertEquals("0", formatQuarksCompact(0, us)) - assertEquals("950", formatQuarksCompact(950 * quarksPerToken, us)) - assertEquals("1.2K", formatQuarksCompact(1_250 * quarksPerToken, us)) - assertEquals("3.4M", formatQuarksCompact(3_450_000 * quarksPerToken, us)) - assertEquals("500M", formatQuarksCompact(500_000_000 * quarksPerToken, us)) + fun `holdings are valued in fiat and drop a zero fraction`() { + assertEquals( + CommunityChatHoldings(amount = "$10,840", memberCount = 14), + chat(summary(10_840_000_000)).communityHoldings(usdf, usd, emptyMap()), + ) } @Test - fun `row text uses holdings quarks and roster member count`() { - val summary = HoldingsSummary( - mint = Mint.usdf, - quarks = 1_250 * quarksPerToken, - holderCount = 9, - asOf = Instant.fromEpochSeconds(0), + fun `holdings keep cents when there is a fraction`() { + assertEquals( + "$1,250.50", + chat(summary(1_250_500_000)).communityHoldings(usdf, usd, emptyMap()).amount, ) + } + + @Test + fun `row falls back to the member count when the server sent no holdings`() { assertEquals( - CommunityChatHoldings(amount = "1.2K", memberCount = 14), - chat(summary).communityHoldings(us), + CommunityChatHoldings(amount = null, memberCount = 14), + chat(null).communityHoldings(usdf, usd, emptyMap()), ) } @Test - fun `row has no holdings text when the server sent none`() { - assertNull(chat(null).communityHoldings(us)) + fun `row falls back to the member count when the token is unknown`() { + assertNull(chat(summary(10_000_000)).communityHoldings(null, usd, emptyMap()).amount) } @Test From d980f7006937a56af8039eabc687e7f0a3e76c56 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 19:14:24 -0400 Subject: [PATCH 09/30] fix(messenger): let the send confirmation sheet be dismissed, drop modal trailing periods, use Figma Send and Buy icons --- .../src/main/res/drawable/ic_currency_buy.xml | 12 ++++++++++++ .../src/main/res/drawable/ic_currency_send.xml | 18 ++++++++++++++++++ .../core/src/main/res/values/strings.xml | 3 ++- .../app/messenger/CurrencySendScreen.kt | 5 +---- .../currencysend/CurrencySendViewModel.kt | 2 +- .../components/info/CurrencyInfoContentV2.kt | 6 +++--- 6 files changed, 37 insertions(+), 9 deletions(-) create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_currency_buy.xml create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_currency_send.xml diff --git a/apps/flipcash/core/src/main/res/drawable/ic_currency_buy.xml b/apps/flipcash/core/src/main/res/drawable/ic_currency_buy.xml new file mode 100644 index 0000000000..44c153edb9 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_currency_buy.xml @@ -0,0 +1,12 @@ + + + diff --git a/apps/flipcash/core/src/main/res/drawable/ic_currency_send.xml b/apps/flipcash/core/src/main/res/drawable/ic_currency_send.xml new file mode 100644 index 0000000000..844884c855 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_currency_send.xml @@ -0,0 +1,18 @@ + + + + diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 98b3f2336b..01e2a306a0 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1867,7 +1867,7 @@ Top Community Chats View All How Chats Are Ranked - Chats are ranked by the total amount of %1$s held by their members. Chats with higher combined holdings appear higher. + Chats are ranked by the total amount of %1$s held by their members. Chats with higher combined holdings appear higher OK %1$s of %2$s held by %3$d member @@ -1877,6 +1877,7 @@ %1$d member %1$d members + Couldn\'t get a fresh rate. Please try again Select Recipient Share %1$s Recent Chats diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt index dd3d94abb5..52824679b9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -230,7 +229,7 @@ private fun RecipientRow(chat: ConversationReference, onClick: () -> Unit) { /** * The confirm sheet over Select Recipient: who is being paid, the amount, the currency with no - * picker, and the swipe. Closed by back, never by a drag or a tap outside it, and not mid-send. + * picker, and the swipe. Closed by a swipe down, a tap on the scrim or back, which returns to the list and sends nothing; locked once the send starts. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -250,9 +249,7 @@ private fun SwipeToSendSheet( ModalBottomSheet( onDismissRequest = { if (!locked) onDismiss() }, sheetState = sheetState, - sheetGesturesEnabled = false, dragHandle = null, - properties = ModalBottomSheetProperties(shouldDismissOnClickOutside = false), containerColor = CodeTheme.colors.background, ) { Column( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt index ecb1803b24..05c5a207c4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt @@ -206,7 +206,7 @@ internal class CurrencySendViewModel @Inject constructor( val (title, message) = when (error) { is ComputeVerifiedFiatError.AmountBelowMinimum -> R.string.error_title_amountTooSmall to R.string.error_description_amountTooSmall - else -> R.string.error_title_staleRates to R.string.error_description_staleRates + else -> R.string.error_title_staleRates to R.string.error_description_currencySendStaleRates } BottomBarManager.showAlert( title = resources.getString(title), 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 beefb09d15..8e37cd09fc 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 @@ -376,9 +376,9 @@ private fun CurrencyActionTiles( ) { currencyActionTiles(tokenMint, isHeld).forEach { tile -> val (label, icon) = when (tile) { - CurrencyActionTile.Send -> R.string.action_send to R.drawable.ic_arrow_up_large - CurrencyActionTile.Buy -> R.string.action_buy to R.drawable.ic_arrow_down - CurrencyActionTile.BuyIn -> R.string.action_buyIn to R.drawable.ic_arrow_down + CurrencyActionTile.Send -> R.string.action_send to R.drawable.ic_currency_send + CurrencyActionTile.Buy -> R.string.action_buy to R.drawable.ic_currency_buy + CurrencyActionTile.BuyIn -> R.string.action_buyIn to R.drawable.ic_currency_buy CurrencyActionTile.Transactions -> R.string.action_transactions to R.drawable.ic_transactions CurrencyActionTile.More -> R.string.action_more to R.drawable.ic_more_horizontal } From e31a8d9359f9ff1336c9edf1ec27bbcfc9c88f52 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 19:19:11 -0400 Subject: [PATCH 10/30] fix(tokens): 24dp action tile icons, drop sub-cent fraction in community holdings --- .../internal/components/info/CurrencyInfoContentV2.kt | 3 ++- .../kotlin/com/flipcash/app/tokens/ui/CommunityChats.kt | 3 +++ .../com/flipcash/app/tokens/ui/CommunityChatsTest.kt | 8 ++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) 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 8e37cd09fc..7d0aa679fd 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 @@ -44,6 +44,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode import androidx.navigation3.ui.LocalNavAnimatedContentScope import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp @@ -390,7 +391,7 @@ private fun CurrencyActionTiles( painter = painterResource(icon), contentDescription = null, tint = CodeTheme.colors.textMain, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), + modifier = Modifier.size(24.dp), ) }, onClick = { diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChats.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChats.kt index 17100f022f..cf0cf249fb 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChats.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChats.kt @@ -38,6 +38,9 @@ fun ChatMetadata.communityHoldings( token ?: return@let null runCatching { Fiat.tokenBalance(summary.quarks, token) }.getOrNull() ?.forViewer(preferredRate, rates) + // Round to cents first: Truncated only drops a fraction that is exactly zero, so a + // sub-cent remainder would show as ".00". + ?.rounded() ?.formatted(Fiat.FormattingRule.Truncated) } return CommunityChatHoldings(amount = amount, memberCount = rosterSummary.memberCount) diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CommunityChatsTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CommunityChatsTest.kt index 162ed9c9f6..ac6f25d050 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CommunityChatsTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CommunityChatsTest.kt @@ -44,6 +44,14 @@ class CommunityChatsTest { ) } + @Test + fun `holdings drop the fraction when it rounds to zero cents`() { + assertEquals( + "$10,840", + chat(summary(10_840_004_000)).communityHoldings(usdf, usd, emptyMap()).amount, + ) + } + @Test fun `holdings keep cents when there is a fraction`() { assertEquals( From 3a44682751a3aca87f4f69c34ffd351b9b20a09c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 19:30:27 -0400 Subject: [PATCH 11/30] fix(messenger): hide the send sheet before leaving Currency Info --- .../app/messenger/CurrencySendScreen.kt | 44 ++++++++++++++++--- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt index 52824679b9..cf2b8cbf09 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt @@ -24,11 +24,15 @@ import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -64,6 +68,7 @@ import com.getcode.solana.keys.Mint * the amount, a recipient from the viewer's DMs, then the swipe. The currency is fixed to [mint]. * After the checkmark the screen pops back to Currency Info. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun CurrencySendScreen(mint: Mint) { val viewModel = hiltViewModel() @@ -71,7 +76,24 @@ fun CurrencySendScreen(mint: Mint) { val navigator = LocalCodeNavigator.current LaunchedEffect(viewModel, mint) { viewModel.start(mint) } - LaunchedEffect(viewModel) { viewModel.done.collect { navigator.pop() } } + // Raised once the checkmark has held: from then on the sheet may close, and only by our hand. + var hiding by remember { mutableStateOf(false) } + val locked by rememberUpdatedState(!state.sendProgress.isIdle && !hiding) + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { !locked }, + ) + LaunchedEffect(viewModel, sheetState) { + viewModel.done.collect { + finishSend( + hide = { + hiding = true + sheetState.hide() + }, + navigateBack = { navigator.pop() }, + ) + } + } BackHandler(enabled = state.stage == CurrencySendViewModel.Stage.Recipients && state.selected == null) { viewModel.onBackToAmount() @@ -132,8 +154,10 @@ fun CurrencySendScreen(mint: Mint) { amountText = amount.formatted(), token = state.token, sendProgress = state.sendProgress, + sheetState = sheetState, + locked = locked, onConfirm = viewModel::send, - onDismiss = viewModel::onConfirmDismissed, + onDismiss = { if (!hiding) viewModel.onConfirmDismissed() }, ) } } @@ -227,6 +251,15 @@ private fun RecipientRow(chat: ConversationReference, onClick: () -> Unit) { } } +/** + * What follows a successful send, in order: the sheet finishes closing, and only then does the + * back stack change. Navigating first would pull the sheet out mid-frame with no hide animation. + */ +internal suspend fun finishSend(hide: suspend () -> Unit, navigateBack: () -> Unit) { + hide() + navigateBack() +} + /** * The confirm sheet over Select Recipient: who is being paid, the amount, the currency with no * picker, and the swipe. Closed by a swipe down, a tap on the scrim or back, which returns to the list and sends nothing; locked once the send starts. @@ -238,14 +271,11 @@ private fun SwipeToSendSheet( amountText: String, token: com.getcode.opencode.model.financial.Token?, sendProgress: com.getcode.view.LoadingSuccessState, + sheetState: SheetState, + locked: Boolean, onConfirm: () -> Unit, onDismiss: () -> Unit, ) { - val locked by rememberUpdatedState(!sendProgress.isIdle) - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true, - confirmValueChange = { !locked }, - ) ModalBottomSheet( onDismissRequest = { if (!locked) onDismiss() }, sheetState = sheetState, From a6c69a9f2c4148eaf84fb79f2a8c5f6425c91fe8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 19:31:00 -0400 Subject: [PATCH 12/30] feat(tokens): show About above the market cap chart --- .../components/info/CurrencyInfoContentV2.kt | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) 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 7d0aa679fd..893ddf8f8d 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 @@ -273,7 +273,29 @@ internal fun CurrencyInfoContentV2( } } - // 4. Market cap (non-USDF only) + // 4. About section (above the market cap chart) + val description = token.description + val socialLinks = token.socialLinks + if (description.isNotBlank() || socialLinks.isNotEmpty()) { + item { + CurrencyAboutSection( + modifier = Modifier + .fillParentMaxWidth() + .padding(top = grid.x5), + description = description, + socialLinks = socialLinks, + isExpanded = state.descriptionExpanded, + inset = inset, + onToggleExpand = { + dispatch( + TokenInfoViewModel.Event.ExpandDescription(!state.descriptionExpanded) + ) + }, + ) + } + } + + // 5. Market cap (non-USDF only) if (!isUsdf) { state.marketCap?.let { mcap -> val historicalData = state.historicalMarketCapData[state.selectedPeriod] @@ -314,28 +336,6 @@ internal fun CurrencyInfoContentV2( } } - // 5. About section - val description = token.description - val socialLinks = token.socialLinks - if (description.isNotBlank() || socialLinks.isNotEmpty()) { - item { - CurrencyAboutSection( - modifier = Modifier - .fillParentMaxWidth() - .padding(top = grid.x5), - description = description, - socialLinks = socialLinks, - isExpanded = state.descriptionExpanded, - inset = inset, - onToggleExpand = { - dispatch( - TokenInfoViewModel.Event.ExpandDescription(!state.descriptionExpanded) - ) - }, - ) - } - } - // 6. Created footer (non-USDF with a known creation date) if (!isUsdf) { token.createdAt?.let { createdAt -> From 37db6412bcc60c09a3a570881e579884beeba168 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 19:34:31 -0400 Subject: [PATCH 13/30] fix(messenger): match Select Recipient row sizing to the design --- .../flipcash/app/messenger/CurrencySendScreen.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt index cf2b8cbf09..fa4b8e76ea 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt @@ -42,6 +42,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.Box import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute @@ -220,27 +224,29 @@ private fun SelectRecipientContent( @Composable private fun RecipientRow(chat: ConversationReference, onClick: () -> Unit) { + // Design: 24 avatar, 16 gap, 48 row; the divider sits under the name column only. Row( modifier = Modifier .fillMaxWidth() + .height(48.dp) .clickable(onClick = onClick) .padding(horizontal = CodeTheme.dimens.inset), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { ContactAvatar( image = chat.image, displayName = chat.name.orEmpty(), access = chat.avatarAccess, modifier = Modifier - .requiredSize(CodeTheme.dimens.staticGrid.x6) + .requiredSize(24.dp) .clip(CircleShape), ) - Column(modifier = Modifier.weight(1f)) { - Column(modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x3)) { + Column(modifier = Modifier.weight(1f).fillMaxHeight()) { + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { Text( text = chat.name.orEmpty(), - style = CodeTheme.typography.textMedium, + style = CodeTheme.typography.textMedium.copy(fontSize = 17.sp), color = CodeTheme.colors.textMain, maxLines = 1, overflow = TextOverflow.Ellipsis, From dfee1665bebff047e2b6f52e3480ff7e2f456888 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 19:36:28 -0400 Subject: [PATCH 14/30] feat(messenger): scroll Select Recipient under a haze button with progressive blur --- .../app/messenger/CurrencySendScreen.kt | 90 ++++++++++++++----- 1 file changed, 66 insertions(+), 24 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt index fa4b8e76ea..c344e3568f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt @@ -42,6 +42,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.rememberHazeState +import dev.chrisbanes.haze.hazeSource +import com.getcode.ui.components.glass.floatingGlass +import com.getcode.ui.components.BottomScrollEdge +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.ui.unit.sp import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxHeight @@ -179,6 +186,14 @@ private fun SelectRecipientContent( snapshotFlow { query.text.toString() }.collect(onQueryChanged) } + // Same arrangement as Edit Featured Groups: the list runs edge to edge behind the bottom + // button, a progressive blur (BottomScrollEdge) fades what passes under it, and the button + // sits on floatingGlass over the same haze source. + val density = LocalDensity.current + val hazeState = rememberHazeState() + val listState = rememberLazyListState() + var barHeight by remember { mutableStateOf(0.dp) } + CodeScaffold( topBar = { AppBarWithTitle( @@ -187,36 +202,63 @@ private fun SelectRecipientContent( onBackIconClicked = onBack, ) }, - bottomBar = { - // Pinned, and present with an empty list too: it is the way out when there is no one to pay. - CodeButton( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(horizontal = CodeTheme.dimens.inset) - .padding(bottom = CodeTheme.dimens.grid.x2), - text = stringResource(R.string.action_orGiveAsCash), - buttonState = ButtonState.Filled10, - onClick = onGiveAsCash, - ) - }, ) { innerPadding -> - Column( + Box( modifier = Modifier .fillMaxSize() - .padding(innerPadding), + .padding(top = innerPadding.calculateTopPadding()), ) { - SearchInput( + Column(modifier = Modifier.fillMaxSize()) { + SearchInput( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(vertical = CodeTheme.dimens.grid.x2), + state = query, + ) + LazyColumn( + state = listState, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .hazeSource(hazeState), + // The button's height and the nav bar, so the last row scrolls fully clear. + contentPadding = PaddingValues(bottom = barHeight), + ) { + items(recipients.orEmpty(), key = { it.chatId.toString() }) { chat -> + RecipientRow(chat = chat, onClick = { onPick(chat) }) + } + } + } + + BottomScrollEdge( + hazeState = hazeState, + visible = true, + height = barHeight, + modifier = Modifier.align(Alignment.BottomCenter), + ) + + // Pinned, and present with an empty list too: it is the way out when there is no one to pay. + Box( modifier = Modifier + .align(Alignment.BottomCenter) .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset) - .padding(vertical = CodeTheme.dimens.grid.x2), - state = query, - ) - LazyColumn(contentPadding = PaddingValues(bottom = CodeTheme.dimens.grid.x4)) { - items(recipients.orEmpty(), key = { it.chatId.toString() }) { chat -> - RecipientRow(chat = chat, onClick = { onPick(chat) }) - } + .onSizeChanged { barHeight = with(density) { it.height.toDp() } }, + ) { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = CodeTheme.dimens.inset) + .padding( + top = CodeTheme.dimens.staticGrid.x3, + bottom = CodeTheme.dimens.grid.x2, + ) + .floatingGlass(hazeState, CodeTheme.shapes.small), + text = stringResource(R.string.action_orGiveAsCash), + buttonState = ButtonState.Filled10, + onClick = onGiveAsCash, + ) } } } From 9f6d6497797d743679d602237c9d3250a16ae0cf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 19:38:29 -0400 Subject: [PATCH 15/30] feat(tokens): card rows for View All chats; share completion shared by root share sheets --- .../messenger/ShareProfileToChatsScreen.kt | 46 +++++++++--------- .../tokens/internal/CommunityChatsScreen.kt | 4 ++ .../components/info/CommunityChatsSection.kt | 48 +++++++++++++++---- 3 files changed, 67 insertions(+), 31 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ShareProfileToChatsScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ShareProfileToChatsScreen.kt index fd39ab51b4..636f376f89 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ShareProfileToChatsScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ShareProfileToChatsScreen.kt @@ -40,18 +40,7 @@ fun ShareProfileToChatsScreen(route: AppRoute.Messaging.ShareProfileToChats) { LaunchedEffect(viewModel, route.directChatId) { viewModel.exclude(route.directChatId) } - // The chat is opened once the sheet is gone, so it is not pushed under one still on its way - // out. The keyboard goes first: the message field has it up, and leaving with it still open - // drags the screen behind out from under it. - LaunchedEffect(viewModel) { - viewModel.invited.collect { chatId -> - keyboard.hideIfVisible { - navigator.pendingSheetDismiss = { - navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId))) - } - } - } - } + OpenChatAfterShare(viewModel) GroupInviteSheet( inviteUrl = link, @@ -83,21 +72,11 @@ fun ShareProfileToChatsScreen(route: AppRoute.Messaging.ShareProfileToChats) { fun ShareCurrencyToChatsScreen(route: AppRoute.Messaging.ShareCurrencyToChats) { val viewModel = hiltViewModel() val state by viewModel.state.collectAsStateWithLifecycle() - val navigator = LocalCodeNavigator.current val dismissSheet = LocalBottomSheetDismissDispatcher.current - val keyboard = rememberKeyboardController() val link = remember(route.mint) { Linkify.tokenInfo(route.mint) } - LaunchedEffect(viewModel) { - viewModel.invited.collect { chatId -> - keyboard.hideIfVisible { - navigator.pendingSheetDismiss = { - navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId))) - } - } - } - } + OpenChatAfterShare(viewModel) GroupInviteSheet( inviteUrl = link, @@ -111,3 +90,24 @@ fun ShareCurrencyToChatsScreen(route: AppRoute.Messaging.ShareCurrencyToChats) { onDismiss = dismissSheet, ) } + +/** + * What every root-level share-to-chats sheet does once the invites have gone out: the keyboard + * goes first (the message field has it up, and leaving with it still open drags the screen behind + * out from under it), then the sheet animates away, and only then is the first-selected chat + * opened, so it is not pushed under a sheet still on its way out. + */ +@Composable +private fun OpenChatAfterShare(viewModel: GroupInviteViewModel) { + val navigator = LocalCodeNavigator.current + val keyboard = rememberKeyboardController() + LaunchedEffect(viewModel) { + viewModel.invited.collect { chatId -> + keyboard.hideIfVisible { + navigator.pendingSheetDismiss = { + navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId))) + } + } + } + } +} diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt index f1136b3313..d318385aaa 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt @@ -11,6 +11,8 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.Arrangement import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute @@ -50,9 +52,11 @@ fun CommunityChatsScreen(mint: Mint) { horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2, ), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { items(state.chats, key = { it.chat.chatId.toString() }) { entry -> CommunityChatRow( + card = true, chat = entry.chat, holdings = entry.holdings, currencyName = state.currencyName, diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt index afb2a691da..43d64888f3 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt @@ -20,6 +20,14 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.graphics.Color +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.background import com.flipcash.app.tokens.ui.CommunityChatHoldings import com.flipcash.features.tokens.R import com.flipcash.services.models.chat.BlobAccessContext @@ -72,31 +80,47 @@ internal fun CommunityChatRow( currencyName: String, onClick: () -> Unit, modifier: Modifier = Modifier, + /** + * The View All card (node 11622:3267, drawn at 0.65 and scaled here): a 5% white fill, 18dp + * corners, 16/12 padding, a 42dp photo, 16/12sp text and a trailing chevron. + */ + card: Boolean = false, ) { - Row( - modifier = modifier + val rowModifier = if (card) { + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(Color.White.copy(alpha = 0.05f)) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp) + } else { + modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(vertical = CodeTheme.dimens.grid.x2), + .padding(vertical = CodeTheme.dimens.grid.x2) + } + Row( + modifier = rowModifier, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + horizontalArrangement = Arrangement.spacedBy(if (card) 12.dp else CodeTheme.dimens.grid.x3), ) { ContactAvatar( image = chat.picture, displayName = chat.title.orEmpty(), access = BlobAccessContext.ChatProfile(chat.chatId), modifier = Modifier - .requiredSize(CodeTheme.dimens.staticGrid.x10) + .requiredSize(if (card) 42.dp else CodeTheme.dimens.staticGrid.x10) .clip(CircleShape), ) Column(modifier = Modifier.weight(1f)) { Text( text = chat.title.orEmpty(), - style = CodeTheme.typography.textMedium, + style = CodeTheme.typography.textMedium.let { if (card) it.copy(fontSize = 16.sp) else it }, color = CodeTheme.colors.textMain, maxLines = 1, overflow = TextOverflow.Ellipsis, ) + if (card) Spacer(Modifier.height(3.dp)) Text( text = holdings.amount?.let { pluralStringResource( @@ -111,11 +135,19 @@ internal fun CommunityChatRow( holdings.memberCount.toInt(), holdings.memberCount.toInt(), ), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, + style = CodeTheme.typography.textSmall.let { if (card) it.copy(fontSize = 12.sp) else it }, + color = if (card) Color.White.copy(alpha = 0.55f) else CodeTheme.colors.textSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } + if (card) { + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = Color.White.copy(alpha = 0.55f), + modifier = Modifier.size(16.dp), + ) + } } } From 198c28722c5796f48a8c045128e58b87236bc3e5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 19:41:26 -0400 Subject: [PATCH 16/30] feat(tokens): per-token Deposit from More; Dollars Deposit opens the deposit choice --- .../src/main/kotlin/com/flipcash/app/core/AppRoute.kt | 9 +++++++-- .../internal/components/info/CurrencyInfoActions.kt | 9 +++------ .../internal/components/info/CurrencyInfoActionsTest.kt | 4 ++-- .../kotlin/com/flipcash/app/tokens/ui/CurrencyActions.kt | 6 +++--- .../com/flipcash/app/tokens/ui/TokenInfoViewModel.kt | 9 +++++++-- .../com/flipcash/app/tokens/ui/CurrencyActionsTest.kt | 4 ++-- 6 files changed, 24 insertions(+), 17 deletions(-) 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 49d3b91398..20aaff45cc 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 @@ -302,9 +302,14 @@ sealed interface AppRoute : NavKey, Parcelable { @Parcelize sealed interface Transfers : AppRoute { @Serializable - data class Deposit(val showOtherOptions: Boolean = true): Transfers, FlowRouteWithResult { + data class Deposit( + val showOtherOptions: Boolean = true, + /** When set, opens straight on that currency's deposit address instead of the USDC intro. */ + val mint: Mint? = null, + ): Transfers, FlowRouteWithResult { override val initialStack: List - get() = listOf(DepositStep.UsdcInformational(showOtherOptions)) + get() = mint?.let { listOf(DepositStep.Destination(it)) } + ?: listOf(DepositStep.UsdcInformational(showOtherOptions)) } /** diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoActions.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoActions.kt index 5f9995305b..5ad8530ec7 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoActions.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoActions.kt @@ -21,10 +21,7 @@ internal fun currencyInfoTiles(isHeld: Boolean, isUsdf: Boolean): List = - if (isUsdf) { - listOf(CurrencyInfoMoreRow.Convert, CurrencyInfoMoreRow.Withdraw) - } else { - listOf(CurrencyInfoMoreRow.Convert, CurrencyInfoMoreRow.Withdraw, CurrencyInfoMoreRow.Deposit) - } + listOf(CurrencyInfoMoreRow.Convert, CurrencyInfoMoreRow.Withdraw, CurrencyInfoMoreRow.Deposit) diff --git a/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoActionsTest.kt b/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoActionsTest.kt index 4478a75fe5..d1d8aac43b 100644 --- a/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoActionsTest.kt +++ b/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoActionsTest.kt @@ -32,13 +32,13 @@ class CurrencyInfoActionsTest { } @Test - fun `more rows include deposit except for usdf`() { + fun `more rows are convert, withdraw and deposit for every currency`() { assertEquals( listOf(CurrencyInfoMoreRow.Convert, CurrencyInfoMoreRow.Withdraw, CurrencyInfoMoreRow.Deposit), currencyInfoMoreRows(isUsdf = false), ) assertEquals( - listOf(CurrencyInfoMoreRow.Convert, CurrencyInfoMoreRow.Withdraw), + listOf(CurrencyInfoMoreRow.Convert, CurrencyInfoMoreRow.Withdraw, CurrencyInfoMoreRow.Deposit), currencyInfoMoreRows(isUsdf = true), ) } diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CurrencyActions.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CurrencyActions.kt index c69343b1ca..365a37948b 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CurrencyActions.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CurrencyActions.kt @@ -27,7 +27,7 @@ fun currencyActionTiles(mint: Mint, isHeld: Boolean): List = /** A row in the More sheet. */ enum class MoreRow { Convert, Withdraw, Deposit } -/** Dollars has no Deposit row: its deposit is the account's own, not a per-currency one. */ +/** Every currency lists Convert, Withdraw and Deposit; Dollars' Deposit opens the deposit-method choice. */ +@Suppress("UNUSED_PARAMETER") fun moreRows(mint: Mint): List = - if (mint == Mint.usdf) listOf(MoreRow.Convert, MoreRow.Withdraw) - else listOf(MoreRow.Convert, MoreRow.Withdraw, MoreRow.Deposit) + listOf(MoreRow.Convert, MoreRow.Withdraw, MoreRow.Deposit) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt index 8d1c86a321..5b4ae0e4be 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt @@ -474,8 +474,13 @@ class TokenInfoViewModel @Inject constructor( subtitle = resources.getString(R.string.subtitle_moreDeposit, name), iconRes = R.drawable.ic_menu_deposit, testTag = "more_action_deposit", - // No per-mint deposit exists yet: this opens the account's USDC deposit. - onClick = { dispatchEvent(Event.OpenScreen(AppRoute.Transfers.Deposit())) }, + // Dollars opens the deposit-method choice (USDC intro, other currencies); + // any other currency opens its own deposit address. + onClick = { + val route = if (mint == Mint.usdf) AppRoute.Transfers.Deposit() + else AppRoute.Transfers.Deposit(mint = mint) + dispatchEvent(Event.OpenScreen(route)) + }, ) } }, diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CurrencyActionsTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CurrencyActionsTest.kt index 16f7446cf1..07cfad1ab9 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CurrencyActionsTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CurrencyActionsTest.kt @@ -35,8 +35,8 @@ class CurrencyActionsTest { } @Test - fun `more lists convert, withdraw and deposit, and dollars has no deposit`() { + fun `more lists convert, withdraw and deposit for every currency`() { assertEquals(listOf(MoreRow.Convert, MoreRow.Withdraw, MoreRow.Deposit), moreRows(jeffy)) - assertEquals(listOf(MoreRow.Convert, MoreRow.Withdraw), moreRows(Mint.usdf)) + assertEquals(listOf(MoreRow.Convert, MoreRow.Withdraw, MoreRow.Deposit), moreRows(Mint.usdf)) } } From 03c429f4063278196d04d45356cdb8270b92a4d5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 19:44:30 -0400 Subject: [PATCH 17/30] test(messenger): cover sheet hide before navigating back --- .../flipcash/app/messenger/FinishSendTest.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/FinishSendTest.kt diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/FinishSendTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/FinishSendTest.kt new file mode 100644 index 0000000000..68cc7999b1 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/FinishSendTest.kt @@ -0,0 +1,17 @@ +package com.flipcash.app.messenger + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class FinishSendTest { + @Test + fun `the sheet is hidden before the back stack changes`() = runTest { + val events = mutableListOf() + finishSend( + hide = { events += "hide start"; events += "hide end" }, + navigateBack = { events += "navigate" }, + ) + assertEquals(listOf("hide start", "hide end", "navigate"), events) + } +} From fcccd6b066b5c69f4a6a8c9a48a6d8a86b2e1fd7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 19:46:11 -0400 Subject: [PATCH 18/30] feat(tokens): Dollars Deposit opens the add-money options --- .../com/flipcash/app/tokens/ui/TokenInfoViewModel.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt index 5b4ae0e4be..4d87e787e8 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt @@ -474,12 +474,14 @@ class TokenInfoViewModel @Inject constructor( subtitle = resources.getString(R.string.subtitle_moreDeposit, name), iconRes = R.drawable.ic_menu_deposit, testTag = "more_action_deposit", - // Dollars opens the deposit-method choice (USDC intro, other currencies); + // Dollars opens the add-money options (debit card, Phantom, other wallet); // any other currency opens its own deposit address. onClick = { - val route = if (mint == Mint.usdf) AppRoute.Transfers.Deposit() - else AppRoute.Transfers.Deposit(mint = mint) - dispatchEvent(Event.OpenScreen(route)) + if (mint == Mint.usdf) { + dispatchEvent(Event.PresentDepositOptions) + } else { + dispatchEvent(Event.OpenScreen(AppRoute.Transfers.Deposit(mint = mint))) + } }, ) } From 50511d1c307d6dc138a57e6085cb9fb2ea776fc4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 19:57:23 -0400 Subject: [PATCH 19/30] refactor(payments): hold payment success for one second, matching iOS --- .../kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt | 1 - .../flipcash/app/messenger/ShareProfileToChatsScreen.kt | 2 -- .../com/flipcash/app/messenger/internal/ChatViewModel.kt | 5 +++-- .../internal/currencysend/CurrencySendViewModel.kt | 3 ++- .../internal/request/RequestDetailsViewModel.kt | 3 ++- .../app/messenger/internal/screens/GroupInviteSheet.kt | 2 +- .../com/flipcash/app/withdrawal/WithdrawalViewModel.kt | 5 +++-- .../src/main/kotlin/com/getcode/view/BaseViewModel.kt | 9 +++++++++ 8 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 32ce5b1874..a541ce1242 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -541,7 +541,6 @@ private fun FlowShareMessageSheet(messageId: Long) { inviteUrl = link, subject = ShareToChatsSubject.Message, state = inviteState, - onShare = {}, onCopy = { viewModel.dispatchEvent(ChatViewModel.Event.CopyMessageLink(messageId)) }, onToggle = inviteViewModel::toggle, onMessageChanged = inviteViewModel::onMessageChanged, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ShareProfileToChatsScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ShareProfileToChatsScreen.kt index 636f376f89..4c5b423640 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ShareProfileToChatsScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ShareProfileToChatsScreen.kt @@ -52,7 +52,6 @@ fun ShareProfileToChatsScreen(route: AppRoute.Messaging.ShareProfileToChats) { ), state = state, // A person's link has no group analytics to fire. - onShare = {}, onCopy = { viewModel.copyProfileLink(link) }, onToggle = viewModel::toggle, onMessageChanged = viewModel::onMessageChanged, @@ -82,7 +81,6 @@ fun ShareCurrencyToChatsScreen(route: AppRoute.Messaging.ShareCurrencyToChats) { inviteUrl = link, subject = ShareToChatsSubject.Currency(route.name), state = state, - onShare = {}, onCopy = { viewModel.copyProfileLink(link) }, onToggle = viewModel::toggle, onMessageChanged = viewModel::onMessageChanged, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 39ca1a62a6..45b01f3324 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal +import com.getcode.view.PaymentSuccessHoldDuration import com.flipcash.shared.chat.ui.settledUsd import com.flipcash.shared.chat.ui.partFor import com.flipcash.shared.chat.media.ChatPhoto @@ -3708,7 +3709,7 @@ internal class ChatViewModel @AssistedInject constructor( result.onSuccess { dispatchEvent(Event.SendStateUpdated(success = true)) - delay(400.milliseconds) + delay(PaymentSuccessHoldDuration) analytics.track(sent(AnalyticsState.SUCCESS, null)) dispatchEvent( Dispatchers.Main, @@ -3927,7 +3928,7 @@ internal class ChatViewModel @AssistedInject constructor( result.onSuccess { dispatchEvent(Event.SendStateUpdated(success = true)) - delay(400.milliseconds) + delay(PaymentSuccessHoldDuration) analytics.track(TransferEvents.sentCash(AnalyticsState.SUCCESS, total.analytics, null)) // The send state stays until the sheet has gone, so Select People doesn't flash back to // the keypad on the way out. The next "$" starts a fresh one. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt index 05c5a207c4..2c5f8d9720 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal.currencysend +import com.getcode.view.PaymentSuccessHoldDuration import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flipcash.analytics.State as AnalyticsState @@ -224,7 +225,7 @@ internal class CurrencySendViewModel @Inject constructor( action = TipAction.SEND, ).onSuccess { _state.update { it.copy(sendProgress = LoadingSuccessState(success = true)) } - delay(400.milliseconds) + delay(PaymentSuccessHoldDuration) analytics.track( TransferEvents.sentCash(AnalyticsState.SUCCESS, verifiedFiat.localFiat.analytics, null) ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt index 9c2dbb8144..e71b4c6894 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal.request +import com.getcode.view.PaymentSuccessHoldDuration import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flipcash.app.core.AppRoute @@ -208,7 +209,7 @@ internal class RequestDetailsViewModel @Inject constructor( payments.pay(pending, token, owner.withTimelockForToken(token)) .onSuccess { _state.update { it.copy(payProgress = LoadingSuccessState(success = true)) } - delay(400.milliseconds) + delay(PaymentSuccessHoldDuration) dismissSheet() } .onFailure { cause -> afterFailedPayment(pending, cause) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index 157e4fd30c..2cce378605 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -194,7 +194,7 @@ internal fun GroupInviteSheet( inviteUrl: String?, subject: ShareToChatsSubject, state: GroupInviteViewModel.State, - onShare: () -> Unit, + onShare: () -> Unit = {}, onCopy: () -> Unit, onToggle: (ChatId) -> Unit, onMessageChanged: (String) -> Unit, diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt index f195a4317d..78f20539d1 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt @@ -1,5 +1,6 @@ package com.flipcash.app.withdrawal +import com.getcode.view.PaymentSuccessHoldDuration import android.content.ClipboardManager import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd @@ -485,7 +486,7 @@ internal class WithdrawalViewModel @Inject constructor( activityFeedCoordinator.fetchSinceLatest() } dispatchEvent(Event.UpdateWithdrawalState(success = true)) - delay(400) + delay(PaymentSuccessHoldDuration) dispatchEvent(Event.OnWithdrawSuccessful) } } @@ -570,7 +571,7 @@ internal class WithdrawalViewModel @Inject constructor( activityFeedCoordinator.fetchSinceLatest() } dispatchEvent(Event.UpdateWithdrawalState(success = true)) - delay(400) + delay(PaymentSuccessHoldDuration) dispatchEvent(Event.OnWithdrawSuccessful) } }, diff --git a/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt b/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt index 93a9b24752..c94b0e2502 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.withContext import kotlin.coroutines.CoroutineContext import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds abstract class BaseViewModel( initialState: ViewState, @@ -83,6 +84,14 @@ abstract class BaseViewModel( */ val SuccessHoldDuration = 500.milliseconds +/** + * How long the checkmark holds on a payment or swipe-to-send sheet before it dismisses. + * + * Longer than [SuccessHoldDuration] because money has moved and the confirmation is worth + * reading. Matches iOS. + */ +val PaymentSuccessHoldDuration = 1.seconds + /** * The least time a confirm button's spinner stays up once it has gone up. * From 512ce2c17bf3b3a993ad106a16880336fc7fa1dd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 20:37:45 -0400 Subject: [PATCH 20/30] refactor(payments): add holdPaymentSuccess and use it on send and payment sheets --- .../app/messenger/internal/ChatViewModel.kt | 31 +++++++++------ .../currencysend/CurrencySendViewModel.kt | 14 ++++--- .../request/RequestDetailsViewModel.kt | 8 ++-- .../app/withdrawal/WithdrawalViewModel.kt | 15 ++++--- .../kotlin/com/getcode/view/BaseViewModel.kt | 10 +++++ .../getcode/view/HoldPaymentSuccessTest.kt | 39 +++++++++++++++++++ 6 files changed, 91 insertions(+), 26 deletions(-) create mode 100644 ui/navigation/src/test/kotlin/com/getcode/view/HoldPaymentSuccessTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 45b01f3324..9c02c19544 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -1,6 +1,7 @@ package com.flipcash.app.messenger.internal import com.getcode.view.PaymentSuccessHoldDuration +import com.getcode.view.holdPaymentSuccess import com.flipcash.shared.chat.ui.settledUsd import com.flipcash.shared.chat.ui.partFor import com.flipcash.shared.chat.media.ChatPhoto @@ -3708,12 +3709,15 @@ internal class ChatViewModel @AssistedInject constructor( } result.onSuccess { - dispatchEvent(Event.SendStateUpdated(success = true)) - delay(PaymentSuccessHoldDuration) - analytics.track(sent(AnalyticsState.SUCCESS, null)) - dispatchEvent( - Dispatchers.Main, - Event.SendComplete(verifiedFiat.localFiat.nativeAmount) + holdPaymentSuccess( + show = { dispatchEvent(Event.SendStateUpdated(success = true)) }, + release = { + analytics.track(sent(AnalyticsState.SUCCESS, null)) + dispatchEvent( + Dispatchers.Main, + Event.SendComplete(verifiedFiat.localFiat.nativeAmount) + ) + }, ) }.onFailure { cause -> dispatchEvent(Event.SendStateUpdated()) @@ -3927,12 +3931,15 @@ internal class ChatViewModel @AssistedInject constructor( ) result.onSuccess { - dispatchEvent(Event.SendStateUpdated(success = true)) - delay(PaymentSuccessHoldDuration) - analytics.track(TransferEvents.sentCash(AnalyticsState.SUCCESS, total.analytics, null)) - // The send state stays until the sheet has gone, so Select People doesn't flash back to - // the keypad on the way out. The next "$" starts a fresh one. - dispatchEvent(Dispatchers.Main, Event.SendComplete(total)) + holdPaymentSuccess( + show = { dispatchEvent(Event.SendStateUpdated(success = true)) }, + release = { + analytics.track(TransferEvents.sentCash(AnalyticsState.SUCCESS, total.analytics, null)) + // The send state stays until the sheet has gone, so Select People doesn't flash back to + // the keypad on the way out. The next "$" starts a fresh one. + dispatchEvent(Dispatchers.Main, Event.SendComplete(total)) + }, + ) }.onFailure { cause -> dispatchEvent(Event.SendStateUpdated()) analytics.track(TransferEvents.sentCash(AnalyticsState.FAILURE, total.analytics, cause.analytics)) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt index 2c5f8d9720..b4ba8c38fe 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt @@ -1,6 +1,7 @@ package com.flipcash.app.messenger.internal.currencysend import com.getcode.view.PaymentSuccessHoldDuration +import com.getcode.view.holdPaymentSuccess import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flipcash.analytics.State as AnalyticsState @@ -224,12 +225,15 @@ internal class CurrencySendViewModel @Inject constructor( origin = TipOrigin.CHAT, action = TipAction.SEND, ).onSuccess { - _state.update { it.copy(sendProgress = LoadingSuccessState(success = true)) } - delay(PaymentSuccessHoldDuration) - analytics.track( - TransferEvents.sentCash(AnalyticsState.SUCCESS, verifiedFiat.localFiat.analytics, null) + holdPaymentSuccess( + show = { _state.update { it.copy(sendProgress = LoadingSuccessState(success = true)) } }, + release = { + analytics.track( + TransferEvents.sentCash(AnalyticsState.SUCCESS, verifiedFiat.localFiat.analytics, null) + ) + _done.trySend(Unit) + }, ) - _done.trySend(Unit) }.onFailure { cause -> _state.update { it.copy(sendProgress = LoadingSuccessState()) } analytics.track( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt index e71b4c6894..052cb02c8f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt @@ -1,6 +1,7 @@ package com.flipcash.app.messenger.internal.request import com.getcode.view.PaymentSuccessHoldDuration +import com.getcode.view.holdPaymentSuccess import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flipcash.app.core.AppRoute @@ -208,9 +209,10 @@ internal class RequestDetailsViewModel @Inject constructor( viewModelScope.launch { payments.pay(pending, token, owner.withTimelockForToken(token)) .onSuccess { - _state.update { it.copy(payProgress = LoadingSuccessState(success = true)) } - delay(PaymentSuccessHoldDuration) - dismissSheet() + holdPaymentSuccess( + show = { _state.update { it.copy(payProgress = LoadingSuccessState(success = true)) } }, + release = { dismissSheet() }, + ) } .onFailure { cause -> afterFailedPayment(pending, cause) } } diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt index 78f20539d1..bb80c26c45 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt @@ -1,6 +1,7 @@ package com.flipcash.app.withdrawal import com.getcode.view.PaymentSuccessHoldDuration +import com.getcode.view.holdPaymentSuccess import android.content.ClipboardManager import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd @@ -485,9 +486,10 @@ internal class WithdrawalViewModel @Inject constructor( coroutineScope { activityFeedCoordinator.fetchSinceLatest() } - dispatchEvent(Event.UpdateWithdrawalState(success = true)) - delay(PaymentSuccessHoldDuration) - dispatchEvent(Event.OnWithdrawSuccessful) + holdPaymentSuccess( + show = { dispatchEvent(Event.UpdateWithdrawalState(success = true)) }, + release = { dispatchEvent(Event.OnWithdrawSuccessful) }, + ) } } ) @@ -570,9 +572,10 @@ internal class WithdrawalViewModel @Inject constructor( coroutineScope { activityFeedCoordinator.fetchSinceLatest() } - dispatchEvent(Event.UpdateWithdrawalState(success = true)) - delay(PaymentSuccessHoldDuration) - dispatchEvent(Event.OnWithdrawSuccessful) + holdPaymentSuccess( + show = { dispatchEvent(Event.UpdateWithdrawalState(success = true)) }, + release = { dispatchEvent(Event.OnWithdrawSuccessful) }, + ) } }, onError = { diff --git a/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt b/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt index c94b0e2502..d753f8aaf8 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt @@ -92,6 +92,16 @@ val SuccessHoldDuration = 500.milliseconds */ val PaymentSuccessHoldDuration = 1.seconds +/** + * Shows payment success, holds it for [PaymentSuccessHoldDuration], then releases. Cancellation + * during the hold skips [release]. Mirrors iOS `PaymentSuccess.release(after:_:)`. + */ +suspend inline fun holdPaymentSuccess(show: () -> Unit, release: () -> Unit) { + show() + delay(PaymentSuccessHoldDuration) + release() +} + /** * The least time a confirm button's spinner stays up once it has gone up. * diff --git a/ui/navigation/src/test/kotlin/com/getcode/view/HoldPaymentSuccessTest.kt b/ui/navigation/src/test/kotlin/com/getcode/view/HoldPaymentSuccessTest.kt new file mode 100644 index 0000000000..4d32e7307f --- /dev/null +++ b/ui/navigation/src/test/kotlin/com/getcode/view/HoldPaymentSuccessTest.kt @@ -0,0 +1,39 @@ +package com.getcode.view + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalCoroutinesApi::class) +class HoldPaymentSuccessTest { + + @Test + fun `release runs only after the hold`() = runTest { + val log = mutableListOf() + launch { holdPaymentSuccess(show = { log += "show" }, release = { log += "release" }) } + runCurrent() + assertEquals(listOf("show"), log) + advanceTimeBy(PaymentSuccessHoldDuration - 1.milliseconds) + runCurrent() + assertEquals(listOf("show"), log) + advanceTimeBy(2.milliseconds) + runCurrent() + assertEquals(listOf("show", "release"), log) + } + + @Test + fun `cancelling during the hold skips release`() = runTest { + val log = mutableListOf() + val job = launch { holdPaymentSuccess(show = { log += "show" }, release = { log += "release" }) } + runCurrent() + job.cancel() + advanceTimeBy(PaymentSuccessHoldDuration * 2) + runCurrent() + assertEquals(listOf("show"), log) + } +} From a986836bfd7beb6766bc09a4275a60ada23e6a7b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 20:44:42 -0400 Subject: [PATCH 21/30] fix(currency-send): push Select Recipient over the amount instead of crossfading --- .../ui/navigation/AppScreenContent.kt | 2 +- .../app/messenger/CurrencySendScreen.kt | 121 ++++++++++-------- .../internal/currencysend/CurrencySendStep.kt | 18 +++ 3 files changed, 88 insertions(+), 53 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendStep.kt 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 c63d4077b3..0342b8e95d 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 @@ -127,7 +127,7 @@ internal fun appEntryProvider( // Sheets (inner content — wrapped in Main.Sheet by navigateTo()) // Route type is `Give` but the screen is the Cash/Give screen the flows call cash_screen. - annotatedEntry(testTag = "currency_send_screen") { key -> CurrencySendScreen(key.mint) } + annotatedEntry(testTag = "currency_send_screen") { key -> CurrencySendScreen(key.mint, resultStateRegistry) } annotatedEntry(testTag = "cash_screen") { key -> CashScreen(key.mint, key.fromTokenInfo, key.amount) } annotatedEntry { key -> TokenSelectScreen(key.purpose) } annotatedEntry { ShareAppScreen() } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt index c344e3568f..3a7ffcde45 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt @@ -1,10 +1,6 @@ package com.flipcash.app.messenger import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -62,7 +58,14 @@ import com.flipcash.features.messenger.R import com.flipcash.shared.amountentry.AmountEntryScreen import com.flipcash.shared.chat.ui.ConversationReference import com.flipcash.shared.common.ui.ContactAvatar +import android.os.Parcelable +import androidx.navigation3.runtime.entryProvider +import com.getcode.navigation.annotatedEntry import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.results.NavResultStateRegistry +import com.getcode.navigation.flow.FlowHost +import com.getcode.navigation.flow.rememberFlowNavigator +import com.flipcash.app.messenger.internal.currencysend.CurrencySendStep import com.getcode.theme.CodeTheme import com.getcode.theme.White10 import com.getcode.ui.components.AppBarDefaults @@ -81,7 +84,7 @@ import com.getcode.solana.keys.Mint */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CurrencySendScreen(mint: Mint) { +fun CurrencySendScreen(mint: Mint, resultStateRegistry: NavResultStateRegistry) { val viewModel = hiltViewModel() val state by viewModel.state.collectAsStateWithLifecycle() val navigator = LocalCodeNavigator.current @@ -106,56 +109,70 @@ fun CurrencySendScreen(mint: Mint) { } } - BackHandler(enabled = state.stage == CurrencySendViewModel.Stage.Recipients && state.selected == null) { - viewModel.onBackToAmount() - } + // Select Recipient slides in over the amount, as Select People does in a group send: the sheet + // has its own nav host, and the view model's stage decides which step is on top. + val inRecipients = state.stage == CurrencySendViewModel.Stage.Recipients - AnimatedContent( - targetState = state.stage, - transitionSpec = { fadeIn() togetherWith fadeOut() }, - label = "currencySendStage", - ) { stage -> - when (stage) { - CurrencySendViewModel.Stage.Amount -> AmountEntryScreen( - controller = viewModel.amountDelegate, - onConfirm = viewModel::onAmountConfirmed, - largeHeader = true, - appBar = { - AppBarWithTitle( - titleAlignment = Alignment.CenterHorizontally, - titleContent = { - // Locked to this currency: no dropdown mark, and nothing to tap. - TokenSelectionPill( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight(), - token = state.token, - showsPicker = false, - ) {} - }, - onBackIconClicked = { navigator.pop() }, - ) - }, - ) + FlowHost( + initialStack = listOf(CurrencySendStep.Amount), + resultStateRegistry = resultStateRegistry, + onExit = { _, _ -> navigator.pop() }, + entryProvider = entryProvider { + annotatedEntry { + val flowNavigator = rememberFlowNavigator() + LaunchedEffect(inRecipients) { + if (inRecipients) flowNavigator.navigateTo(CurrencySendStep.Recipients) + } + AmountEntryScreen( + controller = viewModel.amountDelegate, + onConfirm = viewModel::onAmountConfirmed, + largeHeader = true, + appBar = { + AppBarWithTitle( + titleAlignment = Alignment.CenterHorizontally, + titleContent = { + // Locked to this currency: no dropdown mark, and nothing to tap. + TokenSelectionPill( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + token = state.token, + showsPicker = false, + ) {} + }, + onBackIconClicked = { navigator.pop() }, + ) + }, + ) + } - CurrencySendViewModel.Stage.Recipients -> SelectRecipientContent( - recipients = state.visibleRecipients, - onQueryChanged = viewModel::onQueryChanged, - onBack = viewModel::onBackToAmount, - onPick = viewModel::onRecipientTapped, - onGiveAsCash = { - val amount = state.amount ?: return@SelectRecipientContent - navigator.push( - AppRoute.Sheets.Give( - mint = mint, - fromTokenInfo = true, - amount = amount.decimalValue, + annotatedEntry { + val flowNavigator = rememberFlowNavigator() + LaunchedEffect(inRecipients) { + if (!inRecipients) flowNavigator.back() + } + // Back goes through the view model, whose stage change pops this step above; + // the nav host popping it alone would leave the stage behind. + BackHandler(enabled = state.selected == null) { viewModel.onBackToAmount() } + SelectRecipientContent( + recipients = state.visibleRecipients, + onQueryChanged = viewModel::onQueryChanged, + onBack = viewModel::onBackToAmount, + onPick = viewModel::onRecipientTapped, + onGiveAsCash = { + val amount = state.amount ?: return@SelectRecipientContent + navigator.push( + AppRoute.Sheets.Give( + mint = mint, + fromTokenInfo = true, + amount = amount.decimalValue, + ) ) - ) - }, - ) - } - } + }, + ) + } + }, + ) val selected = state.selected val amount = state.amount diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendStep.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendStep.kt new file mode 100644 index 0000000000..daee666617 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendStep.kt @@ -0,0 +1,18 @@ +package com.flipcash.app.messenger.internal.currencysend + +import android.os.Parcelable +import com.getcode.navigation.NonDraggableRoute +import com.getcode.navigation.flow.FlowStep +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +/** The screens inside the Currency Info send sheet, on its own nav host so Select Recipient slides in over the amount. */ +internal sealed interface CurrencySendStep : FlowStep, Parcelable, NonDraggableRoute { + @Parcelize + @Serializable + data object Amount : CurrencySendStep + + @Parcelize + @Serializable + data object Recipients : CurrencySendStep +} From 3d82eb7bd56cf5c93988e79cc2662254a26257f9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 20:47:00 -0400 Subject: [PATCH 22/30] feat(currency-send): present the bill over the recipient list; use history icon for Transactions --- .../res/drawable/ic_currency_transactions.xml | 10 +++++ .../app/messenger/CurrencySendScreen.kt | 16 +++----- .../currencysend/CurrencySendViewModel.kt | 39 +++++++++++++++++++ .../components/info/CurrencyInfoContentV2.kt | 2 +- 4 files changed, 56 insertions(+), 11 deletions(-) create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_currency_transactions.xml diff --git a/apps/flipcash/core/src/main/res/drawable/ic_currency_transactions.xml b/apps/flipcash/core/src/main/res/drawable/ic_currency_transactions.xml new file mode 100644 index 0000000000..91bc878ad8 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_currency_transactions.xml @@ -0,0 +1,10 @@ + + + + diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt index 3a7ffcde45..acf38b4a37 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.foundation.layout.Box import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute +import com.flipcash.app.session.LocalSessionController import com.flipcash.app.core.ui.TokenSelectionPill import com.flipcash.app.messenger.internal.currencysend.CurrencySendViewModel import com.flipcash.features.messenger.R @@ -90,6 +91,10 @@ fun CurrencySendScreen(mint: Mint, resultStateRegistry: NavResultStateRegistry) val navigator = LocalCodeNavigator.current LaunchedEffect(viewModel, mint) { viewModel.start(mint) } + val session = LocalSessionController.current + LaunchedEffect(viewModel, session) { + viewModel.bill.collect { session?.showBill(it) } + } // Raised once the checkmark has held: from then on the sheet may close, and only by our hand. var hiding by remember { mutableStateOf(false) } val locked by rememberUpdatedState(!state.sendProgress.isIdle && !hiding) @@ -159,16 +164,7 @@ fun CurrencySendScreen(mint: Mint, resultStateRegistry: NavResultStateRegistry) onQueryChanged = viewModel::onQueryChanged, onBack = viewModel::onBackToAmount, onPick = viewModel::onRecipientTapped, - onGiveAsCash = { - val amount = state.amount ?: return@SelectRecipientContent - navigator.push( - AppRoute.Sheets.Give( - mint = mint, - fromTokenInfo = true, - amount = amount.decimalValue, - ) - ) - }, + onGiveAsCash = viewModel::giveAsCash, ) } }, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt index b4ba8c38fe..9f86a62d78 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt @@ -50,6 +50,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import com.flipcash.app.core.bill.Scannable import javax.inject.Inject import kotlin.math.min import kotlin.time.Duration.Companion.milliseconds @@ -97,6 +98,11 @@ internal class CurrencySendViewModel @Inject constructor( private val mint = MutableStateFlow(null) + private val _bill = Channel(Channel.BUFFERED) + + /** A bill for the entered amount, priced and ready to present. */ + val bill: Flow = _bill.receiveAsFlow() + private val _done = Channel(Channel.BUFFERED) /** Fires once the checkmark has held, which is where the whole flow goes back to Currency Info. */ @@ -185,6 +191,39 @@ internal class CurrencySendViewModel @Inject constructor( _state.update { it.copy(selected = null) } } + /** Or Give As Cash: prices the entered amount in this currency and hands the bill to the screen. */ + fun giveAsCash() { + val current = _state.value + val token = current.token ?: return + val amount = current.amount ?: return + viewModelScope.launch { + val verifiedFiat = verifiedFiatCalculator.compute( + amount = amount, + token = token, + balance = tokenCoordinator.balanceForToken(token), + rate = exchange.preferredRate, + ).getOrElse { error -> + val (title, message) = when (error) { + is ComputeVerifiedFiatError.AmountBelowMinimum -> + R.string.error_title_amountTooSmall to R.string.error_description_amountTooSmall + else -> R.string.error_title_staleRates to R.string.error_description_staleRates + } + BottomBarManager.showAlert( + title = resources.getString(title), + message = resources.getString(message), + ) + return@launch + } + _bill.trySend( + Scannable.Payable.forToken( + token = token, + amount = verifiedFiat.localFiat, + verifiedState = verifiedFiat.verifiedState, + ) + ) + } + } + fun send() { val current = _state.value val recipient = current.selected ?: return 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 893ddf8f8d..729c8b79d2 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 @@ -380,7 +380,7 @@ private fun CurrencyActionTiles( CurrencyActionTile.Send -> R.string.action_send to R.drawable.ic_currency_send CurrencyActionTile.Buy -> R.string.action_buy to R.drawable.ic_currency_buy CurrencyActionTile.BuyIn -> R.string.action_buyIn to R.drawable.ic_currency_buy - CurrencyActionTile.Transactions -> R.string.action_transactions to R.drawable.ic_transactions + CurrencyActionTile.Transactions -> R.string.action_transactions to R.drawable.ic_currency_transactions CurrencyActionTile.More -> R.string.action_more to R.drawable.ic_more_horizontal } ActionTile( From 3dd816f04f950f559d70c34ed42ed072083f49b4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 20:49:34 -0400 Subject: [PATCH 23/30] fix(currency-info): card rows for community chats and design-matched action tiles --- .../components/info/CurrencyInfoContentV2.kt | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) 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 729c8b79d2..a02666904c 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 @@ -29,6 +29,10 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -251,11 +255,14 @@ internal fun CurrencyInfoContentV2( }, ) } - items(state.communityChats.take(TopCommunityChatsPreviewCount), key = { it.chat.chatId.toString() }) { entry -> + itemsIndexed(state.communityChats.take(TopCommunityChatsPreviewCount), key = { _, it -> it.chat.chatId.toString() }) { index, entry -> CommunityChatRow( + card = true, + // Cards 10dp apart, 14dp under the header (node 11622:1621, 0.65 scale). modifier = Modifier .fillParentMaxWidth() - .padding(horizontal = inset), + .padding(horizontal = inset) + .padding(top = if (index == 0) 14.dp else 10.dp), chat = entry.chat, holdings = entry.holdings, currencyName = token.name, @@ -373,7 +380,8 @@ private fun CurrencyActionTiles( ) { Row( modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + // Node 11622:1609, drawn at 0.65: 5.2 gaps, i.e. 8dp. + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { currencyActionTiles(tokenMint, isHeld).forEach { tile -> val (label, icon) = when (tile) { @@ -425,19 +433,22 @@ private fun ActionTile( Column( modifier = modifier .alpha(if (enabled) 1f else DisabledActionTileAlpha) - .height(CodeTheme.dimens.staticGrid.x18) - .clip(CodeTheme.shapes.extraSmall) + .height(88.dp) + .clip(RoundedCornerShape(12.dp)) .background(Color.White.copy(alpha = 0.1f)) .clickable(enabled = enabled, onClick = onClick), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { icon() - Spacer(Modifier.height(CodeTheme.dimens.grid.x1)) + Spacer(Modifier.height(8.dp)) + // 12sp medium in textMain, as drawn (7.8 at 0.65): "Transactions" fits one line. Text( text = label, - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, + style = CodeTheme.typography.textSmall.copy(fontSize = 12.sp, fontWeight = FontWeight.Medium), + color = CodeTheme.colors.textMain, + maxLines = 1, + softWrap = false, ) } } From d053d952e4900cf7040dbbd080260e38725aceae Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 21:00:18 -0400 Subject: [PATCH 24/30] refactor(currency-info): use grid tokens for spacing and sizes --- .../com/flipcash/app/messenger/CurrencySendScreen.kt | 10 +++++----- .../app/tokens/internal/CommunityChatsScreen.kt | 3 +-- .../internal/components/info/CommunityChatsSection.kt | 10 +++++----- .../internal/components/info/CurrencyInfoContentV2.kt | 10 +++++----- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt index acf38b4a37..50a1965cc5 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt @@ -283,18 +283,18 @@ private fun RecipientRow(chat: ConversationReference, onClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() - .height(48.dp) + .height(CodeTheme.dimens.staticGrid.x12) .clickable(onClick = onClick) .padding(horizontal = CodeTheme.dimens.inset), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x4), ) { ContactAvatar( image = chat.image, displayName = chat.name.orEmpty(), access = chat.avatarAccess, modifier = Modifier - .requiredSize(24.dp) + .requiredSize(CodeTheme.dimens.staticGrid.x6) .clip(CircleShape), ) Column(modifier = Modifier.weight(1f).fillMaxHeight()) { @@ -386,8 +386,8 @@ private fun SwipeToSendSheet( textStyle = CodeTheme.typography.textMedium, imageSize = CodeTheme.dimens.staticGrid.x3, contentPadding = PaddingValues( - horizontal = CodeTheme.dimens.grid.x1 + 1.dp, - vertical = CodeTheme.dimens.grid.x1 - 1.dp, + horizontal = CodeTheme.dimens.staticGrid.x1, + vertical = CodeTheme.dimens.staticGrid.x1, ), showsPicker = false, onClick = {}, diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt index d318385aaa..6997bd06b1 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.compose.foundation.layout.Arrangement import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -52,7 +51,7 @@ fun CommunityChatsScreen(mint: Mint) { horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2, ), - verticalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x3), ) { items(state.chats, key = { it.chat.chatId.toString() }) { entry -> CommunityChatRow( diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt index 43d64888f3..b08e7007af 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt @@ -92,7 +92,7 @@ internal fun CommunityChatRow( .clip(RoundedCornerShape(18.dp)) .background(Color.White.copy(alpha = 0.05f)) .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp) + .padding(horizontal = CodeTheme.dimens.staticGrid.x4, vertical = CodeTheme.dimens.staticGrid.x3) } else { modifier .fillMaxWidth() @@ -102,14 +102,14 @@ internal fun CommunityChatRow( Row( modifier = rowModifier, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(if (card) 12.dp else CodeTheme.dimens.grid.x3), + horizontalArrangement = Arrangement.spacedBy(if (card) CodeTheme.dimens.staticGrid.x3 else CodeTheme.dimens.grid.x3), ) { ContactAvatar( image = chat.picture, displayName = chat.title.orEmpty(), access = BlobAccessContext.ChatProfile(chat.chatId), modifier = Modifier - .requiredSize(if (card) 42.dp else CodeTheme.dimens.staticGrid.x10) + .requiredSize(CodeTheme.dimens.staticGrid.x10) .clip(CircleShape), ) Column(modifier = Modifier.weight(1f)) { @@ -120,7 +120,7 @@ internal fun CommunityChatRow( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - if (card) Spacer(Modifier.height(3.dp)) + if (card) Spacer(Modifier.height(CodeTheme.dimens.staticGrid.x1)) Text( text = holdings.amount?.let { pluralStringResource( @@ -146,7 +146,7 @@ internal fun CommunityChatRow( painter = painterResource(R.drawable.ic_chevron_right), contentDescription = null, tint = Color.White.copy(alpha = 0.55f), - modifier = Modifier.size(16.dp), + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), ) } } 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 a02666904c..7db94e74a3 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 @@ -262,7 +262,7 @@ internal fun CurrencyInfoContentV2( modifier = Modifier .fillParentMaxWidth() .padding(horizontal = inset) - .padding(top = if (index == 0) 14.dp else 10.dp), + .padding(top = if (index == 0) CodeTheme.dimens.staticGrid.x4 else CodeTheme.dimens.staticGrid.x3), chat = entry.chat, holdings = entry.holdings, currencyName = token.name, @@ -381,7 +381,7 @@ private fun CurrencyActionTiles( Row( modifier = modifier, // Node 11622:1609, drawn at 0.65: 5.2 gaps, i.e. 8dp. - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { currencyActionTiles(tokenMint, isHeld).forEach { tile -> val (label, icon) = when (tile) { @@ -399,7 +399,7 @@ private fun CurrencyActionTiles( painter = painterResource(icon), contentDescription = null, tint = CodeTheme.colors.textMain, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), ) }, onClick = { @@ -433,7 +433,7 @@ private fun ActionTile( Column( modifier = modifier .alpha(if (enabled) 1f else DisabledActionTileAlpha) - .height(88.dp) + .height(CodeTheme.dimens.staticGrid.x20 + CodeTheme.dimens.staticGrid.x2) .clip(RoundedCornerShape(12.dp)) .background(Color.White.copy(alpha = 0.1f)) .clickable(enabled = enabled, onClick = onClick), @@ -441,7 +441,7 @@ private fun ActionTile( verticalArrangement = Arrangement.Center, ) { icon() - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(CodeTheme.dimens.grid.x2)) // 12sp medium in textMain, as drawn (7.8 at 0.65): "Transactions" fits one line. Text( text = label, From 637d33070a472db6de02816d465e454d0ff2755a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 07:34:13 -0400 Subject: [PATCH 25/30] refactor(currency-info): spacing and heights on grid, images on staticGrid --- .../com/flipcash/app/messenger/CurrencySendScreen.kt | 10 +++++----- .../app/tokens/internal/CommunityChatsScreen.kt | 2 +- .../internal/components/info/CommunityChatsSection.kt | 6 +++--- .../internal/components/info/CurrencyInfoContentV2.kt | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt index 50a1965cc5..496b78b02b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt @@ -264,7 +264,7 @@ private fun SelectRecipientContent( .navigationBarsPadding() .padding(horizontal = CodeTheme.dimens.inset) .padding( - top = CodeTheme.dimens.staticGrid.x3, + top = CodeTheme.dimens.grid.x2, bottom = CodeTheme.dimens.grid.x2, ) .floatingGlass(hazeState, CodeTheme.shapes.small), @@ -283,11 +283,11 @@ private fun RecipientRow(chat: ConversationReference, onClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() - .height(CodeTheme.dimens.staticGrid.x12) + .height(CodeTheme.dimens.grid.x10) .clickable(onClick = onClick) .padding(horizontal = CodeTheme.dimens.inset), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x4), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), ) { ContactAvatar( image = chat.image, @@ -386,8 +386,8 @@ private fun SwipeToSendSheet( textStyle = CodeTheme.typography.textMedium, imageSize = CodeTheme.dimens.staticGrid.x3, contentPadding = PaddingValues( - horizontal = CodeTheme.dimens.staticGrid.x1, - vertical = CodeTheme.dimens.staticGrid.x1, + horizontal = CodeTheme.dimens.grid.x1, + vertical = CodeTheme.dimens.grid.x1, ), showsPicker = false, onClick = {}, diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt index 6997bd06b1..dae89ed7be 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt @@ -51,7 +51,7 @@ fun CommunityChatsScreen(mint: Mint) { horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2, ), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x3), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { items(state.chats, key = { it.chat.chatId.toString() }) { entry -> CommunityChatRow( diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt index b08e7007af..e90fc32b28 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt @@ -92,7 +92,7 @@ internal fun CommunityChatRow( .clip(RoundedCornerShape(18.dp)) .background(Color.White.copy(alpha = 0.05f)) .clickable(onClick = onClick) - .padding(horizontal = CodeTheme.dimens.staticGrid.x4, vertical = CodeTheme.dimens.staticGrid.x3) + .padding(horizontal = CodeTheme.dimens.grid.x3, vertical = CodeTheme.dimens.grid.x2) } else { modifier .fillMaxWidth() @@ -102,7 +102,7 @@ internal fun CommunityChatRow( Row( modifier = rowModifier, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(if (card) CodeTheme.dimens.staticGrid.x3 else CodeTheme.dimens.grid.x3), + horizontalArrangement = Arrangement.spacedBy(if (card) CodeTheme.dimens.grid.x2 else CodeTheme.dimens.grid.x3), ) { ContactAvatar( image = chat.picture, @@ -120,7 +120,7 @@ internal fun CommunityChatRow( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - if (card) Spacer(Modifier.height(CodeTheme.dimens.staticGrid.x1)) + if (card) Spacer(Modifier.height(CodeTheme.dimens.grid.x1)) Text( text = holdings.amount?.let { pluralStringResource( 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 7db94e74a3..84f148c4a3 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 @@ -262,7 +262,7 @@ internal fun CurrencyInfoContentV2( modifier = Modifier .fillParentMaxWidth() .padding(horizontal = inset) - .padding(top = if (index == 0) CodeTheme.dimens.staticGrid.x4 else CodeTheme.dimens.staticGrid.x3), + .padding(top = if (index == 0) CodeTheme.dimens.grid.x3 else CodeTheme.dimens.grid.x2), chat = entry.chat, holdings = entry.holdings, currencyName = token.name, @@ -433,7 +433,7 @@ private fun ActionTile( Column( modifier = modifier .alpha(if (enabled) 1f else DisabledActionTileAlpha) - .height(CodeTheme.dimens.staticGrid.x20 + CodeTheme.dimens.staticGrid.x2) + .height(CodeTheme.dimens.grid.x18) .clip(RoundedCornerShape(12.dp)) .background(Color.White.copy(alpha = 0.1f)) .clickable(enabled = enabled, onClick = onClick), From f87d65ed6c31a408f4d33993558b3ef2638d15bf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 11:15:48 -0400 Subject: [PATCH 26/30] fix(currency-info): match Figma sizes, weights and colours on the send screens Currency Info tiles, chat cards (42 photo, 16 chevron, 67 tall), View All (white, Medium 13), About (Regular 14), footer (Created Jan 15, 2026, Regular 12), Select Recipient and Share (24 avatars, 17 names), swipe sheet (SemiBold 44 amount, 36 chip without chevron, 56 avatar) and the Enter Amount subtitle ("$X available", Medium 16). Spacing and heights use CodeTheme.dimens.grid. Recipient and share rows fold Unicode styled letters (NFKC) so a name such as mathematical-bold Godswill is drawn in the app font instead of a fallback. --- .../core/src/main/res/values/strings.xml | 1 + .../app/messenger/CurrencySendScreen.kt | 142 ++++++++++++------ .../currencysend/CurrencySendViewModel.kt | 9 +- .../internal/screens/GroupInviteSheet.kt | 84 ++++++----- .../components/info/CommunityChatsSection.kt | 59 +++++--- .../components/info/CurrencyInfoContentV2.kt | 38 +++-- .../internal/components/info/SocialChip.kt | 6 +- 7 files changed, 215 insertions(+), 124 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 01e2a306a0..01880b39b8 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -55,6 +55,7 @@ You can only give up to %1$s Enter up to %1$s + %1$s available You can only send up to %1$s Transaction Limit Reached diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt index 496b78b02b..7c605ad599 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt @@ -41,11 +41,12 @@ import androidx.compose.ui.unit.dp import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.hazeSource import com.getcode.ui.components.glass.floatingGlass -import com.getcode.ui.components.BottomScrollEdge import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.layout.onSizeChanged import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.ui.unit.sp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.Box @@ -68,7 +69,10 @@ import com.getcode.navigation.flow.FlowHost import com.getcode.navigation.flow.rememberFlowNavigator import com.flipcash.app.messenger.internal.currencysend.CurrencySendStep import com.getcode.theme.CodeTheme -import com.getcode.theme.White10 +import com.getcode.theme.White05 +import com.flipcash.shared.amountentry.AmountEntryHint +import com.flipcash.app.messenger.internal.screens.foldStyledLetters +import com.flipcash.app.messenger.internal.screens.bottomEdgeScrim import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.SearchInput @@ -132,6 +136,21 @@ fun CurrencySendScreen(mint: Mint, resultStateRegistry: NavResultStateRegistry) controller = viewModel.amountDelegate, onConfirm = viewModel::onAmountConfirmed, largeHeader = true, + headerCaption = { + val available by viewModel.availableText.collectAsStateWithLifecycle() + val config by viewModel.amountDelegate.config.collectAsStateWithLifecycle() + // Over the limit the delegate's own error line takes over. + if (config.hint !is AmountEntryHint.Error) { + available?.let { + Text( + text = it, + // Medium 16 in #ffffff8c. + style = CodeTheme.typography.textMessage, + color = Color(0x8CFFFFFF), + ) + } + } + }, appBar = { AppBarWithTitle( titleAlignment = Alignment.CenterHorizontally, @@ -200,7 +219,7 @@ private fun SelectRecipientContent( } // Same arrangement as Edit Featured Groups: the list runs edge to edge behind the bottom - // button, a progressive blur (BottomScrollEdge) fades what passes under it, and the button + // button, the Invite People colour scrim fades what passes under it, and the button // sits on floatingGlass over the same haze source. val density = LocalDensity.current val hazeState = rememberHazeState() @@ -244,11 +263,13 @@ private fun SelectRecipientContent( } } - BottomScrollEdge( - hazeState = hazeState, - visible = true, - height = barHeight, - modifier = Modifier.align(Alignment.BottomCenter), + // The Invite People list's fade: a colour scrim from the surface up over the rows. + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(barHeight) + .bottomEdgeScrim(CodeTheme.colors.surface, CodeTheme.dimens.grid.x10), ) // Pinned, and present with an empty list too: it is the way out when there is no one to pay. @@ -279,7 +300,7 @@ private fun SelectRecipientContent( @Composable private fun RecipientRow(chat: ConversationReference, onClick: () -> Unit) { - // Design: 24 avatar, 16 gap, 48 row; the divider sits under the name column only. + // Design (node 11622:1826): 24 avatar, 16 gap, 52 row; the divider sits under the name column only. Row( modifier = Modifier .fillMaxWidth() @@ -294,13 +315,13 @@ private fun RecipientRow(chat: ConversationReference, onClick: () -> Unit) { displayName = chat.name.orEmpty(), access = chat.avatarAccess, modifier = Modifier - .requiredSize(CodeTheme.dimens.staticGrid.x6) + .requiredSize(CodeTheme.dimens.staticGrid.x5) .clip(CircleShape), ) Column(modifier = Modifier.weight(1f).fillMaxHeight()) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { Text( - text = chat.name.orEmpty(), + text = chat.name.orEmpty().foldStyledLetters(), style = CodeTheme.typography.textMedium.copy(fontSize = 17.sp), color = CodeTheme.colors.textMain, maxLines = 1, @@ -350,53 +371,74 @@ private fun SwipeToSendSheet( .padding(horizontal = CodeTheme.dimens.inset) .padding(top = CodeTheme.dimens.grid.x6, bottom = CodeTheme.dimens.grid.x4), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + // Section gap 40 (node 11622:2232). + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x8), ) { - ContactAvatar( - image = recipient.image, - displayName = recipient.name.orEmpty(), - access = recipient.avatarAccess, - modifier = Modifier - .requiredSize(CodeTheme.dimens.staticGrid.x12) - .clip(CircleShape), - ) - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = recipient.name.orEmpty(), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - textAlign = TextAlign.Center, + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + ContactAvatar( + image = recipient.image, + displayName = recipient.name.orEmpty(), + access = recipient.avatarAccess, + modifier = Modifier + .requiredSize(CodeTheme.dimens.staticGrid.x11) + .clip(CircleShape), ) - recipient.handle?.let { + Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = "@${it.removePrefix("@")}", - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, + text = recipient.name.orEmpty().foldStyledLetters(), + // SemiBold 19 (node 11622:2236). + style = CodeTheme.typography.textLarge.copy(fontSize = 19.sp), + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, ) + recipient.handle?.let { + Text( + text = "@${it.removePrefix("@")}", + // Regular 13 in #ffffff8c. + style = CodeTheme.typography.textSmall.copy( + fontSize = 13.sp, + fontWeight = FontWeight.Normal, + ), + color = Color(0x8CFFFFFF), + ) + } } } - Text( - text = amountText, - style = CodeTheme.typography.displayMedium, - color = CodeTheme.colors.textMain, - ) - TokenSelectionPill( - token = token, - background = White10, - textStyle = CodeTheme.typography.textMedium, - imageSize = CodeTheme.dimens.staticGrid.x3, - contentPadding = PaddingValues( - horizontal = CodeTheme.dimens.grid.x1, - vertical = CodeTheme.dimens.grid.x1, - ), - showsPicker = false, - onClick = {}, - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + Text( + text = amountText, + // SemiBold 44, tracking -1 (node 11881:2467). + style = CodeTheme.typography.displayMedium.copy( + fontSize = 44.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-1).sp, + ), + color = CodeTheme.colors.textMain, + ) + // Node 11881:2468: 36 tall, 20 coin, SemiBold 17, 5% fill. No chevron: the currency is locked. + TokenSelectionPill( + modifier = Modifier.height(CodeTheme.dimens.grid.x7), + token = token, + background = White05, + textStyle = CodeTheme.typography.textMedium.copy(fontSize = 17.sp), + imageSize = CodeTheme.dimens.staticGrid.x4, + contentPadding = PaddingValues( + horizontal = CodeTheme.dimens.grid.x2, + vertical = CodeTheme.dimens.grid.x1, + ), + showsPicker = false, + onClick = {}, + ) + } SlideToConfirm( onConfirm = onConfirm, - modifier = Modifier - .fillMaxWidth() - .padding(top = CodeTheme.dimens.grid.x3), + modifier = Modifier.fillMaxWidth(), enabled = sendProgress.isIdle, isLoading = sendProgress.loading, isSuccess = sendProgress.success, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt index 9f86a62d78..7dc7d47523 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt @@ -127,7 +127,9 @@ internal class CurrencySendViewModel @Inject constructor( style = AmountEntryStyle( actionLabel = AmountEntryLabel.Plain(resources.getString(R.string.action_next)), actionStyle = ConfirmationStyle.Button, - infoHint = { resources.getString(R.string.subtitle_sendHint, it) }, + // "$X available" is drawn by the screen in its own style (see [availableText]), so the + // delegate's own line stays empty until the entry goes over the limit. + infoHint = { "" }, overMaxHint = { resources.getString(R.string.subtitle_sendHintLimitExceeded, it) }, standingHint = AmountEntryStyle.StandingHint.Ceiling, ), @@ -135,6 +137,11 @@ internal class CurrencySendViewModel @Inject constructor( maxAmount = maxAmount, ) + /** "$X available": what the line under the amount says (node 11622:1762), as iOS words it. */ + val availableText: StateFlow = maxAmount + .map { max -> max?.let { resources.getString(R.string.subtitle_currencySendAvailable, it.formatted()) } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + init { viewModelScope.launch { val selfId = userManager.accountId diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index 2cce378605..c0dcc7aee7 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -47,6 +47,9 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import com.getcode.theme.extraSmall +import androidx.compose.ui.unit.sp import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset @@ -125,6 +128,24 @@ internal fun CodeNavigator.openMessageShare(messageId: Long) { } } +/** + * The colour scrim that fades a list out into the bottom edge: [color] opaque at the bottom of the + * box, fading to transparent [tail] above its top. Drawn behind the box's content. Shared by the + * Invite People sheet and Select Recipient, so the two lists fade alike. + */ +internal fun Modifier.bottomEdgeScrim(color: Color, tail: Dp): Modifier = drawBehind { + val scrimHeight = size.height + tail.toPx() + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, color), + startY = size.height - scrimHeight, + endY = size.height, + ), + topLeft = Offset(0f, size.height - scrimHeight), + size = size.copy(height = scrimHeight), + ) +} + /** Test id of the Show Group Card tile. */ internal const val GroupCardTileTag = "group-card-tile" @@ -272,7 +293,7 @@ internal fun GroupInviteSheet( .height(IntrinsicSize.Min) .padding(horizontal = ListInset) .padding(top = TileRowTop, bottom = TileRowBottom), - horizontalArrangement = Arrangement.spacedBy(TileGap), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { InviteLinkTile( modifier = Modifier.weight(1f).fillMaxHeight(), @@ -357,18 +378,7 @@ internal fun GroupInviteSheet( .align(Alignment.BottomCenter) .fillMaxWidth() .height(headerHeight) - .drawBehind { - val scrimHeight = size.height + ScrimTail.toPx() - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.Transparent, scrimColor), - startY = size.height - scrimHeight, - endY = size.height, - ), - topLeft = Offset(0f, size.height - scrimHeight), - size = size.copy(height = scrimHeight), - ) - }, + .bottomEdgeScrim(scrimColor, ScrimTail), ) AppBarWithTitle( @@ -448,19 +458,19 @@ private fun InviteLinkTile( Column( modifier = modifier .then(if (testTag != null) Modifier.testTag(testTag) else Modifier) - .heightIn(min = TileMinHeight) - .clip(TileShape) + .heightIn(min = CodeTheme.dimens.grid.x19) + .clip(CodeTheme.shapes.extraSmall) .background(White05) .clickable(role = Role.Button, onClick = onClick) - .padding(horizontal = TilePaddingHorizontal, vertical = TilePaddingVertical), + .padding(horizontal = CodeTheme.dimens.grid.x2, vertical = CodeTheme.dimens.grid.x3), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(TileIconGap, Alignment.CenterVertically), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1, Alignment.CenterVertically), ) { Icon( painter = painterResource(icon), contentDescription = null, tint = CodeTheme.colors.textMain, - modifier = Modifier.size(TileIconSize), + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), ) Text( text = label, @@ -474,9 +484,12 @@ private fun InviteLinkTile( /** Node 10330:19568 — the list's label, an ordinary row that scrolls with the chats. */ @Composable private fun RecentChatsHeader() { - Column(modifier = Modifier.padding(horizontal = ListInset)) { + // Node 11633:2259: SemiBold 14, 16 above and below; the divider under it runs edge to edge. + Column { Text( - modifier = Modifier.padding(vertical = HeaderRowVertical), + modifier = Modifier + .padding(horizontal = ListInset) + .padding(vertical = CodeTheme.dimens.grid.x3), text = stringResource(R.string.label_recentChats), style = CodeTheme.typography.textSmall, color = White50, @@ -507,25 +520,27 @@ private fun RecentChatRow( ) .padding(horizontal = ListInset), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(RowGap), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), ) { ContactAvatar( image = chat.image, displayName = chat.name.orEmpty(), access = chat.avatarAccess, modifier = Modifier - .requiredSize(CodeTheme.dimens.staticGrid.x6) + .requiredSize(CodeTheme.dimens.staticGrid.x5) .clip(CircleShape), ) Column(modifier = Modifier.weight(1f)) { Row( - modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x4), + // Node 11633:2262: 52 tall including the 1 divider. + modifier = Modifier.height(CodeTheme.dimens.grid.x10), verticalAlignment = Alignment.CenterVertically, ) { Text( modifier = Modifier.weight(1f), - text = chat.name.orEmpty(), - style = CodeTheme.typography.textMedium, + text = chat.name.orEmpty().foldStyledLetters(), + // SemiBold 17. + style = CodeTheme.typography.textMedium.copy(fontSize = 17.sp), color = CodeTheme.colors.textMain, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -533,6 +548,7 @@ private fun RecentChatRow( // The same mark the token and currency pickers use, so this reads as the same kind // of list. Image( + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5), painter = painterResource( if (selected) R.drawable.ic_checked else R.drawable.ic_unchecked ), @@ -594,23 +610,13 @@ private val ListInset = 20.dp private val ComposerFill = Color(0x751E1E1E) private val ComposerBlurRadius = 24.dp private val HeaderPadding = 16.dp -private val HeaderRowVertical = 16.dp // More above than below. AppBarWithTitle holds its height fixed and ignores HeaderPadding's bottom, // while its top pushes the close button down past the bar's measured edge, which is where the list // starts. iOS pads its header 16 on every side over the same 12 row inset; the extra 16 here stands // in for that bottom padding. private val TileRowTop = 28.dp private val TileRowBottom = 12.dp -private val TileGap = 10.dp -private val TileMinHeight = 95.dp -private val TileShape = RoundedCornerShape(6.dp) -private val TilePaddingHorizontal = 8.dp -private val TilePaddingVertical = 16.dp -private val TileIconSize = 28.dp -private val TileIconGap = 6.dp -// Node 10330:19572: the gap between avatar and name, which is also where the row's divider starts. -private val RowGap = 16.dp private val ComposerMinBottom = 8.dp @@ -620,3 +626,11 @@ private val ScrimTail = 48.dp private val ComposerTravel = 120.dp // iOS's spring(response: 0.35): stiffness (2π / 0.35)² and its default damping. private val ComposerSpring = spring(dampingRatio = 0.825f, stiffness = 322f) + +/** + * Names can arrive in Unicode "styled" letters (e.g. U+1D5D4 mathematical sans-serif bold), which + * the app font has no glyphs for, so those rows fell back to a system font. NFKC folds them to + * the plain letters the app font draws; plain names are unchanged. + */ +internal fun String.foldStyledLetters(): String = + java.text.Normalizer.normalize(this, java.text.Normalizer.Form.NFKC) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt index e90fc32b28..4811053b97 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt @@ -19,11 +19,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp import androidx.compose.ui.res.painterResource import androidx.compose.ui.graphics.Color -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.Spacer @@ -34,8 +34,15 @@ import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 -/** "Top Community Chats", its info button and View All, then the chats given. */ +/** The Figma "snippet" text colour, #ffffff8c, for secondary lines on a dark card. */ +internal val SnippetText = Color(0x8CFFFFFF) + +/** + * "Top Community Chats", its info button and View All (nodes 11622:1616-1620): a 19 SemiBold title, + * a 14 glyph, and View All in white at 13 Medium, in a row 28 tall. + */ @Composable internal fun CommunityChatsHeader( onInfo: () -> Unit, @@ -43,21 +50,23 @@ internal fun CommunityChatsHeader( modifier: Modifier = Modifier, ) { Row( - modifier = modifier.fillMaxWidth(), + modifier = modifier + .fillMaxWidth() + .height(CodeTheme.dimens.grid.x6), verticalAlignment = Alignment.CenterVertically, ) { Text( text = stringResource(R.string.title_topCommunityChats), - style = CodeTheme.typography.textMedium, + style = CodeTheme.typography.textLarge.copy(fontSize = 19.sp), color = CodeTheme.colors.textMain, ) Icon( imageVector = Icons.Outlined.Info, contentDescription = stringResource(R.string.title_howChatsAreRanked), - tint = CodeTheme.colors.textSecondary, + tint = SnippetText, modifier = Modifier .padding(start = CodeTheme.dimens.grid.x1) - .requiredSize(CodeTheme.dimens.staticGrid.x4) + .requiredSize(CodeTheme.dimens.staticGrid.x3) .clip(CircleShape) .clickable(onClick = onInfo), ) @@ -66,13 +75,18 @@ internal fun CommunityChatsHeader( .weight(1f) .clickable(onClick = onViewAll), text = stringResource(R.string.action_viewAll), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - textAlign = androidx.compose.ui.text.style.TextAlign.End, + // Medium 13: the caption style is the true Medium face; no theme style is 13. + style = CodeTheme.typography.caption.copy(fontSize = 13.sp), + color = CodeTheme.colors.textMain, + textAlign = TextAlign.End, ) } } +/** + * A chat card (node 11622:1622), on Currency Info and View All: 12 radius, a 5% white fill, a 42 + * photo, 16 SemiBold title over a 12 Regular subtitle, and a trailing 16 chevron, 67 tall. + */ @Composable internal fun CommunityChatRow( chat: ChatMetadata, @@ -80,19 +94,16 @@ internal fun CommunityChatRow( currencyName: String, onClick: () -> Unit, modifier: Modifier = Modifier, - /** - * The View All card (node 11622:3267, drawn at 0.65 and scaled here): a 5% white fill, 18dp - * corners, 16/12 padding, a 42dp photo, 16/12sp text and a trailing chevron. - */ card: Boolean = false, ) { val rowModifier = if (card) { modifier .fillMaxWidth() - .clip(RoundedCornerShape(18.dp)) - .background(Color.White.copy(alpha = 0.05f)) + .height(CodeTheme.dimens.grid.x13) + .clip(CodeTheme.shapes.medium) + .background(White05) .clickable(onClick = onClick) - .padding(horizontal = CodeTheme.dimens.grid.x3, vertical = CodeTheme.dimens.grid.x2) + .padding(horizontal = CodeTheme.dimens.grid.x3) } else { modifier .fillMaxWidth() @@ -109,13 +120,13 @@ internal fun CommunityChatRow( displayName = chat.title.orEmpty(), access = BlobAccessContext.ChatProfile(chat.chatId), modifier = Modifier - .requiredSize(CodeTheme.dimens.staticGrid.x10) + .requiredSize(if (card) CodeTheme.dimens.staticGrid.x8 else CodeTheme.dimens.staticGrid.x10) .clip(CircleShape), ) Column(modifier = Modifier.weight(1f)) { Text( text = chat.title.orEmpty(), - style = CodeTheme.typography.textMedium.let { if (card) it.copy(fontSize = 16.sp) else it }, + style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textMain, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -135,8 +146,10 @@ internal fun CommunityChatRow( holdings.memberCount.toInt(), holdings.memberCount.toInt(), ), - style = CodeTheme.typography.textSmall.let { if (card) it.copy(fontSize = 12.sp) else it }, - color = if (card) Color.White.copy(alpha = 0.55f) else CodeTheme.colors.textSecondary, + style = CodeTheme.typography.textSmall.let { + if (card) it.copy(fontSize = 12.sp, fontWeight = FontWeight.Normal) else it + }, + color = if (card) SnippetText else CodeTheme.colors.textSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -145,8 +158,8 @@ internal fun CommunityChatRow( Icon( painter = painterResource(R.drawable.ic_chevron_right), contentDescription = null, - tint = Color.White.copy(alpha = 0.55f), - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), + tint = SnippetText, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x3), ) } } 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 84f148c4a3..1930f76381 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 @@ -72,6 +72,7 @@ import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.SocialLink import com.getcode.solana.keys.Mint import com.getcode.theme.CodeTheme +import com.getcode.theme.White10 import com.getcode.theme.extraSmall import com.getcode.ui.components.text.ExpandableText import com.getcode.ui.core.addIf @@ -347,15 +348,20 @@ internal fun CurrencyInfoContentV2( if (!isUsdf) { token.createdAt?.let { createdAt -> item { - val formattedDate = createdAt.format("MMMM dd, yyyy") + val formattedDate = createdAt.format("MMM d, yyyy") Text( modifier = Modifier .fillParentMaxWidth() .padding(top = grid.x6, bottom = grid.x6) .padding(horizontal = inset), - text = stringResource(R.string.label_createdAt, formattedDate).uppercase(), - style = CodeTheme.typography.caption, - color = CodeTheme.colors.textMain.copy(alpha = 0.3f), + text = stringResource(R.string.label_createdAt, formattedDate), + // Regular 12, line height 1.4 (node 11622:1678). + style = CodeTheme.typography.textSmall.copy( + fontSize = 12.sp, + fontWeight = FontWeight.Normal, + lineHeight = 16.8.sp, + ), + color = SnippetText, textAlign = TextAlign.Center, ) } @@ -399,7 +405,7 @@ private fun CurrencyActionTiles( painter = painterResource(icon), contentDescription = null, tint = CodeTheme.colors.textMain, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5), ) }, onClick = { @@ -434,18 +440,18 @@ private fun ActionTile( modifier = modifier .alpha(if (enabled) 1f else DisabledActionTileAlpha) .height(CodeTheme.dimens.grid.x18) - .clip(RoundedCornerShape(12.dp)) - .background(Color.White.copy(alpha = 0.1f)) + .clip(CodeTheme.shapes.small) + .background(White10) .clickable(enabled = enabled, onClick = onClick), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { icon() Spacer(Modifier.height(CodeTheme.dimens.grid.x2)) - // 12sp medium in textMain, as drawn (7.8 at 0.65): "Transactions" fits one line. + // Medium 12, line height 1.4, in textMain (node 11622:1610): "Transactions" fits one line. Text( text = label, - style = CodeTheme.typography.textSmall.copy(fontSize = 12.sp, fontWeight = FontWeight.Medium), + style = CodeTheme.typography.caption.copy(lineHeight = 16.8.sp), color = CodeTheme.colors.textMain, maxLines = 1, softWrap = false, @@ -468,13 +474,17 @@ private fun CurrencyAboutSection( modifier = Modifier.padding(horizontal = inset), text = stringResource(R.string.subtitle_about), style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain.copy(alpha = 0.6f), + color = CodeTheme.colors.textMain, ) ExpandableText( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3), text = description, - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textSecondary, + // Regular 14, line height 1.4 (node 11622:1627). + style = CodeTheme.typography.textSmall.copy( + fontWeight = FontWeight.Normal, + lineHeight = 19.6.sp, + ), + color = SnippetText, isExpanded = isExpanded, contentPadding = PaddingValues(horizontal = inset), onToggle = onToggleExpand, @@ -485,7 +495,7 @@ private fun CurrencyAboutSection( LazyRow( modifier = Modifier .fillMaxWidth() - .padding(top = CodeTheme.dimens.grid.x4), + .padding(top = CodeTheme.dimens.grid.x3), horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), contentPadding = PaddingValues(horizontal = inset), ) { diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/SocialChip.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/SocialChip.kt index 01eea1cd05..af57f6d454 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/SocialChip.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/SocialChip.kt @@ -9,6 +9,8 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.size +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter @@ -52,6 +54,7 @@ internal fun SocialChip( } ), contentDescription = null, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x3), colorFilter = ColorFilter.tint(CodeTheme.colors.textSecondary) ) @@ -63,7 +66,8 @@ internal fun SocialChip( is SocialLink.Telegram -> stringResource(R.string.label_social_tg) }, color = CodeTheme.colors.textMain, - style = CodeTheme.typography.textMedium + // Regular 16 (node 11622:1636). + style = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.Normal) ) } } \ No newline at end of file From 8db00d1650db936243ebdc2b7c609691e2f7b4a0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 11:22:01 -0400 Subject: [PATCH 27/30] fix(currency-send): progressive blur under Give As Cash, Figma search field Restore BottomScrollEdge on Select Recipient and put Invite People's inline scrim back. Select Recipient's search is a filled 46 tall, 10 radius, 8% white field with no border, set at the call site so the shared SearchInput is unchanged. The Share sheet's Recent Chats divider keeps the rows' horizontal insets. --- .../app/messenger/CurrencySendScreen.kt | 78 ++++++++++++++++--- .../internal/screens/GroupInviteSheet.kt | 39 ++++------ 2 files changed, 82 insertions(+), 35 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt index 7c605ad599..adcdda7387 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute @@ -72,10 +73,19 @@ import com.getcode.theme.CodeTheme import com.getcode.theme.White05 import com.flipcash.shared.amountentry.AmountEntryHint import com.flipcash.app.messenger.internal.screens.foldStyledLetters -import com.flipcash.app.messenger.internal.screens.bottomEdgeScrim +import com.getcode.ui.components.BottomScrollEdge +import com.getcode.ui.components.TextInput +import com.getcode.theme.inputColors +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.clearText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material3.Icon +import com.getcode.theme.White50 +import com.getcode.ui.core.unboundedClickable import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle -import com.getcode.ui.components.SearchInput import com.getcode.ui.components.SlideToConfirm import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton @@ -219,7 +229,7 @@ private fun SelectRecipientContent( } // Same arrangement as Edit Featured Groups: the list runs edge to edge behind the bottom - // button, the Invite People colour scrim fades what passes under it, and the button + // button, a progressive blur (BottomScrollEdge) fades what passes under it, and the button // sits on floatingGlass over the same haze source. val density = LocalDensity.current val hazeState = rememberHazeState() @@ -241,7 +251,7 @@ private fun SelectRecipientContent( .padding(top = innerPadding.calculateTopPadding()), ) { Column(modifier = Modifier.fillMaxSize()) { - SearchInput( + RecipientSearchField( modifier = Modifier .fillMaxWidth() .padding(horizontal = CodeTheme.dimens.inset) @@ -263,13 +273,11 @@ private fun SelectRecipientContent( } } - // The Invite People list's fade: a colour scrim from the surface up over the rows. - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .height(barHeight) - .bottomEdgeScrim(CodeTheme.colors.surface, CodeTheme.dimens.grid.x10), + BottomScrollEdge( + hazeState = hazeState, + visible = true, + height = barHeight, + modifier = Modifier.align(Alignment.BottomCenter), ) // Pinned, and present with an empty list too: it is the way out when there is no one to pay. @@ -298,6 +306,54 @@ private fun SelectRecipientContent( } } +/** + * Select Recipient's search field (Figma): a filled rectangle with no border, 46 tall, 10 radius, + * 8% white, an 18 search glyph, a 10 gap and 14 padding, with a Regular 17 placeholder at 50% white. + * Local to this screen; the shared SearchInput keeps its outlined pill for the other lists. + */ +@Composable +private fun RecipientSearchField(state: androidx.compose.foundation.text.input.TextFieldState, modifier: Modifier = Modifier) { + val textStyle = CodeTheme.typography.textMedium.copy(fontSize = 17.sp, fontWeight = FontWeight.Normal) + TextInput( + modifier = modifier, + state = state, + minHeight = CodeTheme.dimens.grid.x11, + placeholder = stringResource(com.getcode.ui.components.R.string.action_search), + placeholderStyle = textStyle, + style = textStyle, + maxLines = 1, + shape = RoundedCornerShape(CodeTheme.dimens.grid.x2), + colors = inputColors( + backgroundColor = Color(0x14FFFFFF), + borderColor = Color.Transparent, + placeholderColor = White50, + ), + leadingIcon = { + Icon( + modifier = Modifier + .padding(start = CodeTheme.dimens.grid.x3) + .size(CodeTheme.dimens.staticGrid.x4), + imageVector = Icons.Filled.Search, + contentDescription = null, + tint = White50, + ) + }, + trailingIcon = { + if (state.text.isNotEmpty()) { + Icon( + modifier = Modifier + .unboundedClickable { state.clearText() } + .padding(end = CodeTheme.dimens.grid.x3) + .size(CodeTheme.dimens.staticGrid.x4), + imageVector = Icons.Outlined.Close, + contentDescription = null, + tint = White50, + ) + } + }, + ) +} + @Composable private fun RecipientRow(chat: ConversationReference, onClick: () -> Unit) { // Design (node 11622:1826): 24 avatar, 16 gap, 52 row; the divider sits under the name column only. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index c0dcc7aee7..d5906ae982 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -128,24 +128,6 @@ internal fun CodeNavigator.openMessageShare(messageId: Long) { } } -/** - * The colour scrim that fades a list out into the bottom edge: [color] opaque at the bottom of the - * box, fading to transparent [tail] above its top. Drawn behind the box's content. Shared by the - * Invite People sheet and Select Recipient, so the two lists fade alike. - */ -internal fun Modifier.bottomEdgeScrim(color: Color, tail: Dp): Modifier = drawBehind { - val scrimHeight = size.height + tail.toPx() - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.Transparent, color), - startY = size.height - scrimHeight, - endY = size.height, - ), - topLeft = Offset(0f, size.height - scrimHeight), - size = size.copy(height = scrimHeight), - ) -} - /** Test id of the Show Group Card tile. */ internal const val GroupCardTileTag = "group-card-tile" @@ -378,7 +360,18 @@ internal fun GroupInviteSheet( .align(Alignment.BottomCenter) .fillMaxWidth() .height(headerHeight) - .bottomEdgeScrim(scrimColor, ScrimTail), + .drawBehind { + val scrimHeight = size.height + ScrimTail.toPx() + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, scrimColor), + startY = size.height - scrimHeight, + endY = size.height, + ), + topLeft = Offset(0f, size.height - scrimHeight), + size = size.copy(height = scrimHeight), + ) + }, ) AppBarWithTitle( @@ -484,12 +477,10 @@ private fun InviteLinkTile( /** Node 10330:19568 — the list's label, an ordinary row that scrolls with the chats. */ @Composable private fun RecentChatsHeader() { - // Node 11633:2259: SemiBold 14, 16 above and below; the divider under it runs edge to edge. - Column { + // Node 11633:2259: SemiBold 14, 16 above and below; the divider keeps the rows' insets. + Column(modifier = Modifier.padding(horizontal = ListInset)) { Text( - modifier = Modifier - .padding(horizontal = ListInset) - .padding(vertical = CodeTheme.dimens.grid.x3), + modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x3), text = stringResource(R.string.label_recentChats), style = CodeTheme.typography.textSmall, color = White50, From e0deba8fa0887c8f36c52ad6b24c1c9b843fffe0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 11:24:15 -0400 Subject: [PATCH 28/30] feat(currency-info): progressive blur at the bottom edge Content scrolling under the nav bar blurs out through BottomScrollEdge, shown while the list can scroll forward, so the Created footer reads clean at the end. --- .../components/info/CurrencyInfoContentV2.kt | 435 +++++++++--------- 1 file changed, 224 insertions(+), 211 deletions(-) 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 1930f76381..2039a2e333 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 @@ -79,6 +79,7 @@ import com.getcode.ui.core.addIf import com.getcode.ui.theme.CodeCircularProgressIndicator import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.HazeState +import com.getcode.ui.components.BottomScrollEdge import dev.chrisbanes.haze.blur.HazeBlurStyle import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.hazeBlur @@ -109,266 +110,278 @@ internal fun CurrencyInfoContentV2( val inset = CodeTheme.dimens.inset val grid = CodeTheme.dimens.grid - LazyColumn( - // Content fills behind the overlaid app bar and scrolls under it; [hazeState] marks it as the - // blur source so the frosted (liquid-glass) bar chrome frosts it. [contentPadding] insets the - // first item below the bar (like the chat screen), and the bar draws its own bg->transparent - // scrim for the soft top fade. - modifier = Modifier - .fillMaxSize() - .addIf(hazeState != null) { Modifier.hazeSource(hazeState!!) }, - state = listState, - contentPadding = contentPadding, - ) { - when (state.token) { - is Loadable.Loading -> { - item { - Box(modifier = Modifier.fillParentMaxSize()) { - Box( - modifier = Modifier - .fillParentMaxSize(0.24f) - .aspectRatio(1f) - .align(Alignment.Center), - ) { - CodeCircularProgressIndicator( - modifier = Modifier.matchParentSize(), - strokeWidth = grid.x1, - color = Color.White, - backgroundColor = Color.White.copy(0.30f), - strokeCap = StrokeCap.Butt, - ) + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + // Content fills behind the overlaid app bar and scrolls under it; [hazeState] marks it as the + // blur source so the frosted (liquid-glass) bar chrome frosts it. [contentPadding] insets the + // first item below the bar (like the chat screen), and the bar draws its own bg->transparent + // scrim for the soft top fade. + modifier = Modifier + .fillMaxSize() + .addIf(hazeState != null) { Modifier.hazeSource(hazeState!!) }, + state = listState, + contentPadding = contentPadding, + ) { + when (state.token) { + is Loadable.Loading -> { + item { + Box(modifier = Modifier.fillParentMaxSize()) { + Box( + modifier = Modifier + .fillParentMaxSize(0.24f) + .aspectRatio(1f) + .align(Alignment.Center), + ) { + CodeCircularProgressIndicator( + modifier = Modifier.matchParentSize(), + strokeWidth = grid.x1, + color = Color.White, + backgroundColor = Color.White.copy(0.30f), + strokeCap = StrokeCap.Butt, + ) + } } } } - } - is Loadable.Error -> { - item { - Box(modifier = Modifier.fillParentMaxSize()) { - Box( - modifier = Modifier - .fillParentMaxSize(0.24f) - .aspectRatio(1f) - .align(Alignment.Center), - ) { - Image( - modifier = Modifier.matchParentSize(), - painter = painterResource(R.drawable.ic_circle_exclamation_large), - contentDescription = null, - ) + is Loadable.Error -> { + item { + Box(modifier = Modifier.fillParentMaxSize()) { + Box( + modifier = Modifier + .fillParentMaxSize(0.24f) + .aspectRatio(1f) + .align(Alignment.Center), + ) { + Image( + modifier = Modifier.matchParentSize(), + painter = painterResource(R.drawable.ic_circle_exclamation_large), + contentDescription = null, + ) + } } } } - } - is Loadable.Loaded -> { - val loadedToken = state.token as Loadable.Loaded - val token = loadedToken.data + is Loadable.Loaded -> { + val loadedToken = state.token as Loadable.Loaded + val token = loadedToken.data - val isUsdf = state.isCashReserve - val isHeld = state.showTransactionHistory || state.balance.nativeAmount.isPositive - val openExplainer = { - dispatch( - TokenInfoViewModel.Event.OpenScreen(AppRoute.Token.MarketCapExplainer(token.address)) - ) - } - - // 1. Hero bill card - item { - val appreciationText = state.appreciation - ?.takeIf { isHeld && state.showAppreciation && it != LocalFiat.MIN_VALUE } - ?.nativeAmount - ?.formattedAppreciation() - - val heroModifier = Modifier - .fillParentMaxWidth() - .padding(horizontal = inset) - .padding(top = grid.x2) - - if (heroAsPlaceholder) { - // Reserve the slot, report its window bounds as the fly target, and reveal this - // real card once the list scrolls (the flying overlay hides then — they're - // coincident at the top, so the swap is invisible). Deferred alpha = no recompose. - TokenCard( - token = token, - balanceText = if (isHeld) state.balance.nativeAmount.formatted() else "", - displayName = token.name, - appreciationText = appreciationText, - modifier = heroModifier - .onGloballyPositioned { onHeroBounds(it.boundsInWindow()) } - .graphicsLayer { alpha = heroPlaceholderAlpha() }, - ) - } else { - // Pushed/deeplink presentation: fly from the tapped wallet deck card (same mint - // key), overlay-hosted while opening and in-layer while closing. - val heroInOverlay = if (LocalInspectionMode.current) { - true - } else { - LocalNavAnimatedContentScope.current.transition.targetState != - EnterExitState.PostExit - } - TokenCard( - token = token, - balanceText = if (isHeld) state.balance.nativeAmount.formatted() else "", - displayName = token.name, - appreciationText = appreciationText, - modifier = heroModifier - .sharedBoundsTransition( - key = SharedTransition.TokenCard(token.address).key, - enter = EnterTransition.None, - exit = ExitTransition.None, - boundsTransform = CardExpandTransition.boundsTransform, - renderInOverlayDuringTransition = heroInOverlay, - ), + val isUsdf = state.isCashReserve + val isHeld = state.showTransactionHistory || state.balance.nativeAmount.isPositive + val openExplainer = { + dispatch( + TokenInfoViewModel.Event.OpenScreen(AppRoute.Token.MarketCapExplainer(token.address)) ) } - } - // 2. Action tiles row - item { - CurrencyActionTiles( - modifier = Modifier + // 1. Hero bill card + item { + val appreciationText = state.appreciation + ?.takeIf { isHeld && state.showAppreciation && it != LocalFiat.MIN_VALUE } + ?.nativeAmount + ?.formattedAppreciation() + + val heroModifier = Modifier .fillParentMaxWidth() .padding(horizontal = inset) - .padding(top = grid.x3), - isHeld = isHeld, - tokenMint = token.address, - shortfall = shortfall, - dispatch = dispatch, - ) - } + .padding(top = grid.x2) - // 3. Top community chats (hidden when empty or the call failed) - if (state.communityChats.isNotEmpty()) { - item { - CommunityChatsHeader( - modifier = Modifier - .fillParentMaxWidth() - .padding(horizontal = inset) - .padding(top = grid.x5), - onInfo = { dispatch(TokenInfoViewModel.Event.OnRankingInfo) }, - onViewAll = { - dispatch( - TokenInfoViewModel.Event.OpenScreen( - AppRoute.Token.CommunityChats(token.address) - ) - ) - }, - ) - } - itemsIndexed(state.communityChats.take(TopCommunityChatsPreviewCount), key = { _, it -> it.chat.chatId.toString() }) { index, entry -> - CommunityChatRow( - card = true, - // Cards 10dp apart, 14dp under the header (node 11622:1621, 0.65 scale). - modifier = Modifier - .fillParentMaxWidth() - .padding(horizontal = inset) - .padding(top = if (index == 0) CodeTheme.dimens.grid.x3 else CodeTheme.dimens.grid.x2), - chat = entry.chat, - holdings = entry.holdings, - currencyName = token.name, - onClick = { - dispatch( - TokenInfoViewModel.Event.OpenScreen( - AppRoute.Messaging.Chat( - identifier = ChatIdentifier.ByChatId(entry.chat.chatId), - openOnProfile = true, - ) - ) - ) - }, - ) + if (heroAsPlaceholder) { + // Reserve the slot, report its window bounds as the fly target, and reveal this + // real card once the list scrolls (the flying overlay hides then — they're + // coincident at the top, so the swap is invisible). Deferred alpha = no recompose. + TokenCard( + token = token, + balanceText = if (isHeld) state.balance.nativeAmount.formatted() else "", + displayName = token.name, + appreciationText = appreciationText, + modifier = heroModifier + .onGloballyPositioned { onHeroBounds(it.boundsInWindow()) } + .graphicsLayer { alpha = heroPlaceholderAlpha() }, + ) + } else { + // Pushed/deeplink presentation: fly from the tapped wallet deck card (same mint + // key), overlay-hosted while opening and in-layer while closing. + val heroInOverlay = if (LocalInspectionMode.current) { + true + } else { + LocalNavAnimatedContentScope.current.transition.targetState != + EnterExitState.PostExit + } + TokenCard( + token = token, + balanceText = if (isHeld) state.balance.nativeAmount.formatted() else "", + displayName = token.name, + appreciationText = appreciationText, + modifier = heroModifier + .sharedBoundsTransition( + key = SharedTransition.TokenCard(token.address).key, + enter = EnterTransition.None, + exit = ExitTransition.None, + boundsTransform = CardExpandTransition.boundsTransform, + renderInOverlayDuringTransition = heroInOverlay, + ), + ) + } } - } - // 4. About section (above the market cap chart) - val description = token.description - val socialLinks = token.socialLinks - if (description.isNotBlank() || socialLinks.isNotEmpty()) { + // 2. Action tiles row item { - CurrencyAboutSection( + CurrencyActionTiles( modifier = Modifier .fillParentMaxWidth() - .padding(top = grid.x5), - description = description, - socialLinks = socialLinks, - isExpanded = state.descriptionExpanded, - inset = inset, - onToggleExpand = { - dispatch( - TokenInfoViewModel.Event.ExpandDescription(!state.descriptionExpanded) - ) - }, + .padding(horizontal = inset) + .padding(top = grid.x3), + isHeld = isHeld, + tokenMint = token.address, + shortfall = shortfall, + dispatch = dispatch, ) } - } - // 5. Market cap (non-USDF only) - if (!isUsdf) { - state.marketCap?.let { mcap -> - val historicalData = state.historicalMarketCapData[state.selectedPeriod] - ?: Loadable.Loaded(emptyList()) + // 3. Top community chats (hidden when empty or the call failed) + if (state.communityChats.isNotEmpty()) { item { - MarketCapSection( + CommunityChatsHeader( modifier = Modifier .fillParentMaxWidth() + .padding(horizontal = inset) .padding(top = grid.x5), - contentPadding = PaddingValues(horizontal = inset), - marketCap = mcap, - selectedPeriod = state.selectedPeriod, - rawHistoricalData = historicalData, - // New v2 UI: no chart draw-in on open (it appears with the card-expand). - animateChartOpen = false, - onRetry = { + onInfo = { dispatch(TokenInfoViewModel.Event.OnRankingInfo) }, + onViewAll = { dispatch( - TokenInfoViewModel.Event.LoadHistoricalDataForPeriod( - state.selectedPeriod + TokenInfoViewModel.Event.OpenScreen( + AppRoute.Token.CommunityChats(token.address) ) ) }, - onPeriodSelected = { - dispatch(TokenInfoViewModel.Event.OnMarketCapPeriodSelected(it)) - }, ) } - item { - MarketCapExplainerRow( + itemsIndexed(state.communityChats.take(TopCommunityChatsPreviewCount), key = { _, it -> it.chat.chatId.toString() }) { index, entry -> + CommunityChatRow( + card = true, + // Cards 10dp apart, 14dp under the header (node 11622:1621, 0.65 scale). modifier = Modifier .fillParentMaxWidth() .padding(horizontal = inset) - .padding(top = grid.x3), - tokenName = token.name, - onClick = openExplainer, + .padding(top = if (index == 0) CodeTheme.dimens.grid.x3 else CodeTheme.dimens.grid.x2), + chat = entry.chat, + holdings = entry.holdings, + currencyName = token.name, + onClick = { + dispatch( + TokenInfoViewModel.Event.OpenScreen( + AppRoute.Messaging.Chat( + identifier = ChatIdentifier.ByChatId(entry.chat.chatId), + openOnProfile = true, + ) + ) + ) + }, ) } } - } - // 6. Created footer (non-USDF with a known creation date) - if (!isUsdf) { - token.createdAt?.let { createdAt -> + // 4. About section (above the market cap chart) + val description = token.description + val socialLinks = token.socialLinks + if (description.isNotBlank() || socialLinks.isNotEmpty()) { item { - val formattedDate = createdAt.format("MMM d, yyyy") - Text( + CurrencyAboutSection( modifier = Modifier .fillParentMaxWidth() - .padding(top = grid.x6, bottom = grid.x6) - .padding(horizontal = inset), - text = stringResource(R.string.label_createdAt, formattedDate), - // Regular 12, line height 1.4 (node 11622:1678). - style = CodeTheme.typography.textSmall.copy( - fontSize = 12.sp, - fontWeight = FontWeight.Normal, - lineHeight = 16.8.sp, - ), - color = SnippetText, - textAlign = TextAlign.Center, + .padding(top = grid.x5), + description = description, + socialLinks = socialLinks, + isExpanded = state.descriptionExpanded, + inset = inset, + onToggleExpand = { + dispatch( + TokenInfoViewModel.Event.ExpandDescription(!state.descriptionExpanded) + ) + }, ) } } + + // 5. Market cap (non-USDF only) + if (!isUsdf) { + state.marketCap?.let { mcap -> + val historicalData = state.historicalMarketCapData[state.selectedPeriod] + ?: Loadable.Loaded(emptyList()) + item { + MarketCapSection( + modifier = Modifier + .fillParentMaxWidth() + .padding(top = grid.x5), + contentPadding = PaddingValues(horizontal = inset), + marketCap = mcap, + selectedPeriod = state.selectedPeriod, + rawHistoricalData = historicalData, + // New v2 UI: no chart draw-in on open (it appears with the card-expand). + animateChartOpen = false, + onRetry = { + dispatch( + TokenInfoViewModel.Event.LoadHistoricalDataForPeriod( + state.selectedPeriod + ) + ) + }, + onPeriodSelected = { + dispatch(TokenInfoViewModel.Event.OnMarketCapPeriodSelected(it)) + }, + ) + } + item { + MarketCapExplainerRow( + modifier = Modifier + .fillParentMaxWidth() + .padding(horizontal = inset) + .padding(top = grid.x3), + tokenName = token.name, + onClick = openExplainer, + ) + } + } + } + + // 6. Created footer (non-USDF with a known creation date) + if (!isUsdf) { + token.createdAt?.let { createdAt -> + item { + val formattedDate = createdAt.format("MMM d, yyyy") + Text( + modifier = Modifier + .fillParentMaxWidth() + .padding(top = grid.x6, bottom = grid.x6) + .padding(horizontal = inset), + text = stringResource(R.string.label_createdAt, formattedDate), + // Regular 12, line height 1.4 (node 11622:1678). + style = CodeTheme.typography.textSmall.copy( + fontSize = 12.sp, + fontWeight = FontWeight.Normal, + lineHeight = 16.8.sp, + ), + color = SnippetText, + textAlign = TextAlign.Center, + ) + } + } + } } } } + + // Content blurs out toward the bottom edge as it scrolls under the nav bar; the same edge the + // other scrolling screens use. Hidden once the list reaches its end, so the footer reads clean. + if (hazeState != null) { + BottomScrollEdge( + modifier = Modifier.align(Alignment.BottomCenter), + hazeState = hazeState, + listState = listState, + ) + } } } From 952d4459da85ba8c3ad61f0cb456a56da3879b3d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 11:29:30 -0400 Subject: [PATCH 29/30] fix(currency-info): use theme text styles instead of Figma Regular --- .../flipcash/app/messenger/CurrencySendScreen.kt | 10 +++------- .../components/info/CommunityChatsSection.kt | 2 +- .../components/info/CurrencyInfoContentV2.kt | 13 ++----------- .../tokens/internal/components/info/SocialChip.kt | 3 +-- 4 files changed, 7 insertions(+), 21 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt index adcdda7387..bd0ac9358a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt @@ -308,12 +308,12 @@ private fun SelectRecipientContent( /** * Select Recipient's search field (Figma): a filled rectangle with no border, 46 tall, 10 radius, - * 8% white, an 18 search glyph, a 10 gap and 14 padding, with a Regular 17 placeholder at 50% white. + * 8% white, an 18 search glyph, a 10 gap and 14 padding, with the theme text style and a 50% white placeholder. * Local to this screen; the shared SearchInput keeps its outlined pill for the other lists. */ @Composable private fun RecipientSearchField(state: androidx.compose.foundation.text.input.TextFieldState, modifier: Modifier = Modifier) { - val textStyle = CodeTheme.typography.textMedium.copy(fontSize = 17.sp, fontWeight = FontWeight.Normal) + val textStyle = CodeTheme.typography.textMedium TextInput( modifier = modifier, state = state, @@ -453,11 +453,7 @@ private fun SwipeToSendSheet( recipient.handle?.let { Text( text = "@${it.removePrefix("@")}", - // Regular 13 in #ffffff8c. - style = CodeTheme.typography.textSmall.copy( - fontSize = 13.sp, - fontWeight = FontWeight.Normal, - ), + style = CodeTheme.typography.textSmall, color = Color(0x8CFFFFFF), ) } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt index 4811053b97..b14f17ac17 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt @@ -147,7 +147,7 @@ internal fun CommunityChatRow( holdings.memberCount.toInt(), ), style = CodeTheme.typography.textSmall.let { - if (card) it.copy(fontSize = 12.sp, fontWeight = FontWeight.Normal) else it + if (card) it.copy(fontSize = 12.sp) else it }, color = if (card) SnippetText else CodeTheme.colors.textSecondary, maxLines = 1, 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 2039a2e333..972b0c7f3a 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 @@ -357,12 +357,7 @@ internal fun CurrencyInfoContentV2( .padding(top = grid.x6, bottom = grid.x6) .padding(horizontal = inset), text = stringResource(R.string.label_createdAt, formattedDate), - // Regular 12, line height 1.4 (node 11622:1678). - style = CodeTheme.typography.textSmall.copy( - fontSize = 12.sp, - fontWeight = FontWeight.Normal, - lineHeight = 16.8.sp, - ), + style = CodeTheme.typography.caption, color = SnippetText, textAlign = TextAlign.Center, ) @@ -492,11 +487,7 @@ private fun CurrencyAboutSection( ExpandableText( modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3), text = description, - // Regular 14, line height 1.4 (node 11622:1627). - style = CodeTheme.typography.textSmall.copy( - fontWeight = FontWeight.Normal, - lineHeight = 19.6.sp, - ), + style = CodeTheme.typography.textMedium, color = SnippetText, isExpanded = isExpanded, contentPadding = PaddingValues(horizontal = inset), diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/SocialChip.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/SocialChip.kt index af57f6d454..e382b1b78b 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/SocialChip.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/SocialChip.kt @@ -66,8 +66,7 @@ internal fun SocialChip( is SocialLink.Telegram -> stringResource(R.string.label_social_tg) }, color = CodeTheme.colors.textMain, - // Regular 16 (node 11622:1636). - style = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.Normal) + style = CodeTheme.typography.textMedium ) } } \ No newline at end of file From 9a6889c8fac1a7e0dab073949231c8fef508b2d6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 11:32:46 -0400 Subject: [PATCH 30/30] fix(currency-info): small text for About and footer --- .../tokens/internal/components/info/CurrencyInfoContentV2.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 972b0c7f3a..95ec7b1507 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 @@ -357,7 +357,7 @@ internal fun CurrencyInfoContentV2( .padding(top = grid.x6, bottom = grid.x6) .padding(horizontal = inset), text = stringResource(R.string.label_createdAt, formattedDate), - style = CodeTheme.typography.caption, + style = CodeTheme.typography.textSmall, color = SnippetText, textAlign = TextAlign.Center, ) @@ -487,7 +487,7 @@ private fun CurrencyAboutSection( ExpandableText( modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3), text = description, - style = CodeTheme.typography.textMedium, + style = CodeTheme.typography.textSmall, color = SnippetText, isExpanded = isExpanded, contentPadding = PaddingValues(horizontal = inset),