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..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 @@ -44,10 +44,13 @@ 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 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 @@ -124,7 +127,8 @@ 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 = "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() } annotatedEntry { ActivityHistoryScreen() } @@ -169,6 +173,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) } @@ -183,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-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/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..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 @@ -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 @@ -230,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, @@ -284,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)) } /** @@ -549,6 +572,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/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/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/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..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 @@ -1858,4 +1859,27 @@ %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 + + + %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/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, ) { 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/CurrencySendScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt new file mode 100644 index 0000000000..bd0ac9358a --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/CurrencySendScreen.kt @@ -0,0 +1,501 @@ +package com.flipcash.app.messenger + +import androidx.activity.compose.BackHandler +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.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 +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 dev.chrisbanes.haze.rememberHazeState +import dev.chrisbanes.haze.hazeSource +import com.getcode.ui.components.glass.floatingGlass +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 +import androidx.compose.foundation.layout.size +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 +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.White05 +import com.flipcash.shared.amountentry.AmountEntryHint +import com.flipcash.app.messenger.internal.screens.foldStyledLetters +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.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. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CurrencySendScreen(mint: Mint, resultStateRegistry: NavResultStateRegistry) { + val viewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + 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) + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { !locked }, + ) + LaunchedEffect(viewModel, sheetState) { + viewModel.done.collect { + finishSend( + hide = { + hiding = true + sheetState.hide() + }, + navigateBack = { navigator.pop() }, + ) + } + } + + // 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 + + 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, + 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, + 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() }, + ) + }, + ) + } + + 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 = viewModel::giveAsCash, + ) + } + }, + ) + + 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, + sheetState = sheetState, + locked = locked, + onConfirm = viewModel::send, + onDismiss = { if (!hiding) 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) + } + + // 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( + title = stringResource(R.string.title_selectRecipient), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = onBack, + ) + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = innerPadding.calculateTopPadding()), + ) { + Column(modifier = Modifier.fillMaxSize()) { + RecipientSearchField( + 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() + .onSizeChanged { barHeight = with(density) { it.height.toDp() } }, + ) { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = CodeTheme.dimens.inset) + .padding( + top = CodeTheme.dimens.grid.x2, + bottom = CodeTheme.dimens.grid.x2, + ) + .floatingGlass(hazeState, CodeTheme.shapes.small), + text = stringResource(R.string.action_orGiveAsCash), + buttonState = ButtonState.Filled10, + onClick = onGiveAsCash, + ) + } + } + } +} + +/** + * 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 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 + 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. + Row( + modifier = Modifier + .fillMaxWidth() + .height(CodeTheme.dimens.grid.x10) + .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.x5) + .clip(CircleShape), + ) + Column(modifier = Modifier.weight(1f).fillMaxHeight()) { + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + Text( + text = chat.name.orEmpty().foldStyledLetters(), + style = CodeTheme.typography.textMedium.copy(fontSize = 17.sp), + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + HorizontalDivider(color = CodeTheme.colors.divider) + } + } +} + +/** + * 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. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SwipeToSendSheet( + recipient: ConversationReference, + amountText: String, + token: com.getcode.opencode.model.financial.Token?, + sendProgress: com.getcode.view.LoadingSuccessState, + sheetState: SheetState, + locked: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + ModalBottomSheet( + onDismissRequest = { if (!locked) onDismiss() }, + sheetState = sheetState, + dragHandle = null, + 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, + // Section gap 40 (node 11622:2232). + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x8), + ) { + 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), + ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + 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("@")}", + style = CodeTheme.typography.textSmall, + color = Color(0x8CFFFFFF), + ) + } + } + } + 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(), + 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/ShareProfileToChatsScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ShareProfileToChatsScreen.kt index ebd897e1d1..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 @@ -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, @@ -63,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, @@ -77,3 +65,47 @@ 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 dismissSheet = LocalBottomSheetDismissDispatcher.current + + val link = remember(route.mint) { Linkify.tokenInfo(route.mint) } + + OpenChatAfterShare(viewModel) + + GroupInviteSheet( + inviteUrl = link, + subject = ShareToChatsSubject.Currency(route.name), + state = state, + onCopy = { viewModel.copyProfileLink(link) }, + onToggle = viewModel::toggle, + onMessageChanged = viewModel::onMessageChanged, + onInvite = { viewModel.invite(link) }, + 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/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..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,5 +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 @@ -3707,12 +3709,15 @@ internal class ChatViewModel @AssistedInject constructor( } result.onSuccess { - dispatchEvent(Event.SendStateUpdated(success = true)) - delay(400.milliseconds) - 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()) @@ -3926,12 +3931,15 @@ internal class ChatViewModel @AssistedInject constructor( ) result.onSuccess { - dispatchEvent(Event.SendStateUpdated(success = true)) - delay(400.milliseconds) - 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/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/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 +} 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..7dc7d47523 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt @@ -0,0 +1,299 @@ +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 +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 com.flipcash.app.core.bill.Scannable +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 _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. */ + 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, + // "$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, + ), + loadingState = MutableStateFlow(LoadingSuccessState()), + 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 + 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) } + } + + /** 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 + 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_currencySendStaleRates + } + 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 { + holdPaymentSuccess( + show = { _state.update { it.copy(sendProgress = LoadingSuccessState(success = true)) } }, + release = { + 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/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..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,5 +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 @@ -207,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(400.milliseconds) - dismissSheet() + holdPaymentSuccess( + show = { _state.update { it.copy(payProgress = LoadingSuccessState(success = true)) } }, + release = { 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 8b75679d32..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 @@ -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 @@ -138,7 +141,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 +170,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 } /** @@ -191,7 +197,7 @@ internal fun GroupInviteSheet( inviteUrl: String?, subject: ShareToChatsSubject, state: GroupInviteViewModel.State, - onShare: () -> Unit, + onShare: () -> Unit = {}, onCopy: () -> Unit, onToggle: (ChatId) -> Unit, onMessageChanged: (String) -> Unit, @@ -269,7 +275,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(), @@ -294,6 +300,7 @@ internal fun GroupInviteSheet( preview = subject.preview, ) ShareToChatsSubject.Message -> Shareable.MessageLink(inviteUrl) + is ShareToChatsSubject.Currency -> Shareable.MessageLink(inviteUrl) } ) } @@ -385,13 +392,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) }, @@ -444,19 +451,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, @@ -470,9 +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 keeps the rows' insets. Column(modifier = Modifier.padding(horizontal = ListInset)) { Text( - modifier = Modifier.padding(vertical = HeaderRowVertical), + modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x3), text = stringResource(R.string.label_recentChats), style = CodeTheme.typography.textSmall, color = White50, @@ -503,25 +511,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, @@ -529,6 +539,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 ), @@ -590,23 +601,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 @@ -616,3 +617,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/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) + } +} 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) + } +} 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..dae89ed7be --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/CommunityChatsScreen.kt @@ -0,0 +1,74 @@ +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.compose.foundation.layout.Arrangement +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, + ), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + items(state.chats, key = { it.chat.chatId.toString() }) { entry -> + CommunityChatRow( + card = true, + chat = entry.chat, + holdings = entry.holdings, + currencyName = state.currencyName, + onClick = { + navigator.push( + AppRoute.Messaging.Chat( + 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 new file mode 100644 index 0000000000..b14f17ac17 --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CommunityChatsSection.kt @@ -0,0 +1,166 @@ +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.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.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 +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 + +/** 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, + onViewAll: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(CodeTheme.dimens.grid.x6), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.title_topCommunityChats), + style = CodeTheme.typography.textLarge.copy(fontSize = 19.sp), + color = CodeTheme.colors.textMain, + ) + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = stringResource(R.string.title_howChatsAreRanked), + tint = SnippetText, + modifier = Modifier + .padding(start = CodeTheme.dimens.grid.x1) + .requiredSize(CodeTheme.dimens.staticGrid.x3) + .clip(CircleShape) + .clickable(onClick = onInfo), + ) + Text( + modifier = Modifier + .weight(1f) + .clickable(onClick = onViewAll), + text = stringResource(R.string.action_viewAll), + // 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, + holdings: CommunityChatHoldings, + currencyName: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + card: Boolean = false, +) { + val rowModifier = if (card) { + modifier + .fillMaxWidth() + .height(CodeTheme.dimens.grid.x13) + .clip(CodeTheme.shapes.medium) + .background(White05) + .clickable(onClick = onClick) + .padding(horizontal = CodeTheme.dimens.grid.x3) + } else { + modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = CodeTheme.dimens.grid.x2) + } + Row( + modifier = rowModifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(if (card) CodeTheme.dimens.grid.x2 else CodeTheme.dimens.grid.x3), + ) { + ContactAvatar( + image = chat.picture, + displayName = chat.title.orEmpty(), + access = BlobAccessContext.ChatProfile(chat.chatId), + modifier = Modifier + .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, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (card) Spacer(Modifier.height(CodeTheme.dimens.grid.x1)) + Text( + text = holdings.amount?.let { + pluralStringResource( + R.plurals.subtitle_communityChatHoldings, + holdings.memberCount.toInt(), + it, + currencyName, + holdings.memberCount.toInt(), + ) + } ?: pluralStringResource( + R.plurals.subtitle_communityChatMembers, + holdings.memberCount.toInt(), + holdings.memberCount.toInt(), + ), + style = CodeTheme.typography.textSmall.let { + if (card) it.copy(fontSize = 12.sp) else it + }, + color = if (card) SnippetText else CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (card) { + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + 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/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..5ad8530ec7 --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoActions.kt @@ -0,0 +1,27 @@ +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, + ) +} + +/** Every currency lists Convert, Withdraw and Deposit. */ +@Suppress("UNUSED_PARAMETER") +internal fun currencyInfoMoreRows(isUsdf: Boolean): List = + listOf(CurrencyInfoMoreRow.Convert, CurrencyInfoMoreRow.Withdraw, CurrencyInfoMoreRow.Deposit) 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..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 @@ -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 @@ -44,6 +48,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 @@ -58,18 +63,23 @@ 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 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 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 @@ -100,244 +110,273 @@ 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 - val isUsdf = state.isCashReserve - val isHeld = state.showTransactionHistory || state.balance.nativeAmount.isPositive - val openExplainer = { - dispatch( - TokenInfoViewModel.Event.OpenScreen(AppRoute.Token.MarketCapExplainer(token.address)) - ) - } + is Loadable.Loaded -> { + val loadedToken = state.token as Loadable.Loaded + val token = loadedToken.data - // 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, - convertEnabled = state.canConvert, - dispatch = dispatch, - ) - } + .padding(top = grid.x2) - // 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 { - dispatch( - TokenInfoViewModel.Event.OpenScreen( - AppRoute.Token.Transactions(token.address) - ) - ) + 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 } - .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) - ) + 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. Market cap (non-USDF only) - if (!isUsdf) { - state.marketCap?.let { mcap -> - val historicalData = state.historicalMarketCapData[state.selectedPeriod] - ?: Loadable.Loaded(emptyList()) + // 2. Action tiles row + item { + CurrencyActionTiles( + modifier = Modifier + .fillParentMaxWidth() + .padding(horizontal = inset) + .padding(top = grid.x3), + isHeld = isHeld, + tokenMint = token.address, + shortfall = shortfall, + dispatch = dispatch, + ) + } + + // 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, + ) + ) + ) + }, ) } } - } - // 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 -> + // 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("MMMM dd, yyyy") - Text( + CurrencyAboutSection( 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), - 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), + style = CodeTheme.typography.textSmall, + 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, + ) + } } } @@ -350,117 +389,47 @@ 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, + // Node 11622:1609, drawn at 0.65: 5.2 gaps, i.e. 8dp. 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_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_currency_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.x5), + ) + }, + 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) + } + }, + ) } } } @@ -478,19 +447,22 @@ private fun ActionTile( Column( modifier = modifier .alpha(if (enabled) 1f else DisabledActionTileAlpha) - .height(CodeTheme.dimens.staticGrid.x18) - .clip(CodeTheme.shapes.extraSmall) - .background(Color.White.copy(alpha = 0.1f)) + .height(CodeTheme.dimens.grid.x18) + .clip(CodeTheme.shapes.small) + .background(White10) .clickable(enabled = enabled, onClick = onClick), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { icon() - Spacer(Modifier.height(CodeTheme.dimens.grid.x1)) + Spacer(Modifier.height(CodeTheme.dimens.grid.x2)) + // Medium 12, line height 1.4, in textMain (node 11622:1610): "Transactions" fits one line. Text( text = label, - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, + style = CodeTheme.typography.caption.copy(lineHeight = 16.8.sp), + color = CodeTheme.colors.textMain, + maxLines = 1, + softWrap = false, ) } } @@ -510,13 +482,13 @@ 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, + style = CodeTheme.typography.textSmall, + color = SnippetText, isExpanded = isExpanded, contentPadding = PaddingValues(horizontal = inset), onToggle = onToggleExpand, @@ -527,7 +499,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..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 @@ -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) ) 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..d1d8aac43b --- /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 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, CurrencyInfoMoreRow.Deposit), + currencyInfoMoreRows(isUsdf = true), + ) + } +} 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..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,5 +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 @@ -484,9 +486,10 @@ internal class WithdrawalViewModel @Inject constructor( coroutineScope { activityFeedCoordinator.fetchSinceLatest() } - dispatchEvent(Event.UpdateWithdrawalState(success = true)) - delay(400) - dispatchEvent(Event.OnWithdrawSuccessful) + holdPaymentSuccess( + show = { dispatchEvent(Event.UpdateWithdrawalState(success = true)) }, + release = { dispatchEvent(Event.OnWithdrawSuccessful) }, + ) } } ) @@ -569,9 +572,10 @@ internal class WithdrawalViewModel @Inject constructor( coroutineScope { activityFeedCoordinator.fetchSinceLatest() } - dispatchEvent(Event.UpdateWithdrawalState(success = true)) - delay(400) - dispatchEvent(Event.OnWithdrawSuccessful) + holdPaymentSuccess( + show = { dispatchEvent(Event.UpdateWithdrawalState(success = true)) }, + release = { dispatchEvent(Event.OnWithdrawSuccessful) }, + ) } }, onError = { 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..cf0cf249fb --- /dev/null +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChats.kt @@ -0,0 +1,56 @@ +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 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 + +/** + * 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() + +/** + * 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". + */ +data class CommunityChatHoldings(val amount: String?, val memberCount: Long) + +/** + * 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) + // 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) +} + +/** 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 new file mode 100644 index 0000000000..a288f02a7f --- /dev/null +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CommunityChatsViewModel.kt @@ -0,0 +1,45 @@ +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.getcode.opencode.exchange.Exchange +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, + private val exchange: Exchange, +) : 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() + 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/CurrencyActions.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CurrencyActions.kt new file mode 100644 index 0000000000..365a37948b --- /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 } + +/** Every currency lists Convert, Withdraw and Deposit; Dollars' Deposit opens the deposit-method choice. */ +@Suppress("UNUSED_PARAMETER") +fun moreRows(mint: Mint): List = + 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 0e4fb8c001..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 @@ -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 @@ -41,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 @@ -52,6 +56,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 +77,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 +128,9 @@ 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 object OnRankingInfo : Event + data class OnCommunityChatsLoaded(val chats: List) : Event data object PresentDepositOptions: Event data class OpenScreen(val screen: AppRoute) : Event data object Exit : Event @@ -379,12 +389,104 @@ 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. + 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()) } + ) + } + } + .onEach { dispatchEvent(Event.OnCommunityChatsLoaded(it)) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { presentMore() } + .launchIn(viewModelScope) + 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) + } + + /** + * 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 = moreRows(mint) + 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", + // Dollars opens the add-money options (debit card, Phantom, other wallet); + // any other currency opens its own deposit address. + onClick = { + if (mint == Mint.usdf) { + dispatchEvent(Event.PresentDepositOptions) + } else { + dispatchEvent(Event.OpenScreen(AppRoute.Transfers.Deposit(mint = mint))) + } + }, + ) + } + }, + ) } companion object { @@ -423,11 +525,14 @@ 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 } 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/CommunityChatsTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CommunityChatsTest.kt new file mode 100644 index 0000000000..ac6f25d050 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CommunityChatsTest.kt @@ -0,0 +1,94 @@ +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.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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +class CommunityChatsTest { + 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, + ) + + 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 `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 `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( + "$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 = null, memberCount = 14), + chat(null).communityHoldings(usdf, usd, emptyMap()), + ) + } + + @Test + 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 + 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/CurrencyActionsTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/CurrencyActionsTest.kt new file mode 100644 index 0000000000..07cfad1ab9 --- /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 for every currency`() { + assertEquals(listOf(MoreRow.Convert, MoreRow.Withdraw, MoreRow.Deposit), moreRows(jeffy)) + assertEquals(listOf(MoreRow.Convert, MoreRow.Withdraw, MoreRow.Deposit), moreRows(Mint.usdf)) + } +} 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)) 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) + } } 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..d753f8aaf8 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,24 @@ 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 + +/** + * 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) + } +}