diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TileButton.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TileButton.kt index fcffab2b3f..1f053a0cdc 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TileButton.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TileButton.kt @@ -2,10 +2,13 @@ package com.flipcash.app.core.ui import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Text @@ -15,17 +18,35 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.getcode.theme.CodeTheme import com.getcode.theme.extraSmall -import androidx.compose.foundation.clickable + +/** + * Controls how a [TileButton] arranges its icon and label. + */ +enum class TileButtonStyle { + /** Icon centered above the label — the default compact tile. */ + Centered, + + /** + * Icon pinned to the top-start and the label to the bottom-start, spread across the + * height of the tile. Used for the larger call-to-action tiles. + */ + Spread, +} @Composable fun TileButton( text: String, icon: Painter, modifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues(vertical = CodeTheme.dimens.grid.x6), + style: TileButtonStyle = TileButtonStyle.Centered, + contentPadding: PaddingValues = when (style) { + TileButtonStyle.Centered -> PaddingValues(vertical = CodeTheme.dimens.grid.x6) + TileButtonStyle.Spread -> PaddingValues(CodeTheme.dimens.grid.x4) + }, onClick: () -> Unit, ) { Box( @@ -39,21 +60,52 @@ fun TileButton( .padding(contentPadding), contentAlignment = Alignment.Center, ) { - Column( - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image( - modifier = Modifier.size(32.dp), - painter = icon, - contentDescription = null, - colorFilter = ColorFilter.tint(CodeTheme.colors.textMain), - ) - Text( - text = text, - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - ) + when (style) { + TileButtonStyle.Centered -> { + Column( + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TileButtonIcon(icon) + Text( + text = text, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + } + } + + TileButtonStyle.Spread -> { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.Start, + ) { + TileButtonIcon(icon, style) + Text( + text = text, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + } + } } } -} \ No newline at end of file +} + +@Composable +private fun TileButtonIcon(icon: Painter, style: TileButtonStyle = TileButtonStyle.Centered) { + val size = when (style) { + TileButtonStyle.Centered -> 32.dp + TileButtonStyle.Spread -> 24.dp + } + Image( + modifier = Modifier.size(size), + painter = icon, + contentDescription = null, + colorFilter = ColorFilter.tint(CodeTheme.colors.textMain), + ) +} diff --git a/apps/flipcash/core/src/main/res/drawable/ic_globe.xml b/apps/flipcash/core/src/main/res/drawable/ic_globe.xml new file mode 100644 index 0000000000..5723df3c42 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_globe.xml @@ -0,0 +1,26 @@ + + + + + + diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt index 739c489a66..23f79d2a84 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt @@ -3,11 +3,13 @@ package com.flipcash.app.balance.internal import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.fillMaxWidth @@ -33,9 +35,11 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.ui.AppreciationStyle import com.flipcash.app.core.ui.TokenCardStack import com.flipcash.app.balance.internal.components.BalanceHeader -import com.flipcash.app.balance.internal.components.OnboardingFunnel -import com.flipcash.app.balance.internal.components.OnboardingItem +import com.flipcash.app.balance.internal.components.NewUserTutorial +import com.flipcash.app.balance.internal.components.TutorialItem import com.flipcash.app.core.navigation.LocalTabBarPadding +import com.flipcash.app.core.ui.TileButton +import com.flipcash.app.core.ui.TileButtonStyle import com.flipcash.app.tokens.ui.SelectTokenViewModel import com.flipcash.features.balance.R import com.flipcash.shared.transactionhistory.ActivityFeedRow @@ -80,7 +84,7 @@ internal fun WalletScreenContent( start = CodeTheme.dimens.inset, end = CodeTheme.dimens.inset, bottom = LocalTabBarPadding.current.calculateBottomPadding() + CodeTheme.dimens.grid.x12, - ) + ), ) { item { // v2 wallet header: 96 dp top / 44 dp bottom per Figma node 8966:1578. @@ -101,20 +105,20 @@ internal fun WalletScreenContent( Spacer(Modifier.height(CodeTheme.dimens.grid.x6)) } - if (!balanceState.isOnboardingComplete) { + if (!balanceState.isNewUserTutorialComplete) { item { - OnboardingFunnel( + NewUserTutorial( modifier = Modifier.fillMaxWidth() .padding(bottom = CodeTheme.dimens.grid.x5), title = stringResource(R.string.title_tipOnboarding), items = balanceState.onboardingItems, ) { item -> when (item) { - is OnboardingItem.AddMoney -> { + is TutorialItem.AddMoney -> { dispatchEvent(WalletViewModel.Event.PresentDepositOptions) } - is OnboardingItem.ScanTipCard -> { - + is TutorialItem.ScanTipCard -> { + dispatchEvent(WalletViewModel.Event.OpenScreen(AppRoute.Main.Scanner)) } } } @@ -139,34 +143,6 @@ internal fun WalletScreenContent( } } - if (balanceState.hasAddedMoney) { - item { - Box( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = { dispatchEvent(WalletViewModel.Event.PresentDepositOptions) }) - .padding(vertical = CodeTheme.dimens.inset), - contentAlignment = Alignment.Center, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), - ) { - Icon( - painter = rememberVectorPainter(Icons.Outlined.AddCircleOutline), - contentDescription = null, - tint = CodeTheme.colors.textSecondary, - ) - Text( - text = stringResource(R.string.action_addMoney), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textSecondary, - ) - } - } - } - } - if (balanceState.transactions.isNotEmpty()) { item(key = "recentHeader") { // Tap the header to dive into the full paged activity history. @@ -206,5 +182,44 @@ internal fun WalletScreenContent( ActivityFeedRow(item = item, modifier = Modifier.fillMaxWidth()) } } + + if (balanceState.hasAddedMoney) { + item { + Spacer(Modifier.height(CodeTheme.dimens.grid.x6)) + } + + item { + // Equal-height tiles: the taller (wrapping) label drives the row height and the + // shorter tile stretches to match, so each tile can spread its icon/label vertically. + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + TileButton( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + style = TileButtonStyle.Spread, + text = stringResource(R.string.action_addMoney), + icon = rememberVectorPainter(Icons.Outlined.AddCircleOutline), + ) { + dispatchEvent(WalletViewModel.Event.PresentDepositOptions) + } + + TileButton( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + style = TileButtonStyle.Spread, + text = stringResource(R.string.action_discoverCurrencies), + icon = painterResource(R.drawable.ic_globe), + ) { + dispatchEvent(WalletViewModel.Event.OpenScreen(AppRoute.Token.Discovery)) + } + } + } + } } } \ No newline at end of file diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt index ce4bf80370..5e6967094f 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt @@ -3,7 +3,7 @@ package com.flipcash.app.balance.internal import androidx.lifecycle.viewModelScope import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.FlipcashAnalyticsService -import com.flipcash.app.balance.internal.components.OnboardingItem +import com.flipcash.app.balance.internal.components.TutorialItem import com.flipcash.app.core.AppRoute import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator import com.flipcash.shared.transactionhistory.TransactionListItem @@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import javax.inject.Inject @@ -42,7 +41,7 @@ internal class WalletViewModel @Inject constructor( ) { data class State( val preferredOnRampProvider: OnRampProvider.Defined? = null, - val onboardingItems: List = emptyList(), + val onboardingItems: List = emptyList(), /** * Preview of the most recent unified cross-token activity — at most [RECENT_PREVIEW_COUNT] * rows. The coordinator owns the mapping and enforces the limit; the full paged history is a @@ -51,14 +50,14 @@ internal class WalletViewModel @Inject constructor( val transactions: List = emptyList(), ) { val hasAddedMoney: Boolean - get() = onboardingItems.find { it is OnboardingItem.AddMoney }?.isCompleted == true + get() = onboardingItems.find { it is TutorialItem.AddMoney }?.isCompleted == true - val isOnboardingComplete: Boolean + val isNewUserTutorialComplete: Boolean get() = onboardingItems.all { it.isCompleted } } sealed interface Event { - data class OnOnboardingItemsUpdated(val items: List): Event + data class OnOnboardingItemsUpdated(val items: List): Event data class OnTransactionsUpdated(val transactions: List) : Event data class OnPreferredOnRampProviderChanged(val provider: OnRampProvider.Defined?) : Event @@ -97,8 +96,8 @@ internal class WalletViewModel @Inject constructor( chatCoordinator.hasEverTipped(), ) { hasAddedMoney, hasTipped -> listOf( - OnboardingItem.AddMoney(isCompleted = hasAddedMoney), - OnboardingItem.ScanTipCard(isCompleted = hasTipped), + TutorialItem.AddMoney(isCompleted = hasAddedMoney), + TutorialItem.ScanTipCard(isCompleted = hasTipped), ) } .onEach { items -> dispatchEvent(Event.OnOnboardingItemsUpdated(items)) } diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/components/OnboardingFunnel.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/components/NewUserTutorial.kt similarity index 94% rename from apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/components/OnboardingFunnel.kt rename to apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/components/NewUserTutorial.kt index cb994e8dc8..c8a9f930cc 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/components/OnboardingFunnel.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/components/NewUserTutorial.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.util.fastForEach import com.flipcash.features.balance.R import com.getcode.theme.CodeTheme -sealed interface OnboardingItem { +sealed interface TutorialItem { val title: String @Composable get val description: String @@ -39,7 +39,7 @@ sealed interface OnboardingItem { @Composable get val isCompleted: Boolean - class AddMoney(override val isCompleted: Boolean) : OnboardingItem { + class AddMoney(override val isCompleted: Boolean) : TutorialItem { override val title: String @Composable get() = stringResource(R.string.title_addMoney) override val description: String @@ -49,7 +49,7 @@ sealed interface OnboardingItem { } - class ScanTipCard(override val isCompleted: Boolean) : OnboardingItem { + class ScanTipCard(override val isCompleted: Boolean) : TutorialItem { override val title: String @Composable get() = stringResource(R.string.title_scanTipCard) override val description: String @@ -60,11 +60,11 @@ sealed interface OnboardingItem { } @Composable -fun OnboardingFunnel( +fun NewUserTutorial( title: String, - items: List, + items: List, modifier: Modifier = Modifier, - onItemClicked: (OnboardingItem) -> Unit, + onItemClicked: (TutorialItem) -> Unit, ) { val completedCount = remember(items) { items.count { it.isCompleted } } @@ -110,7 +110,7 @@ fun OnboardingFunnel( @Composable private fun OnboardingItemRow( - item: OnboardingItem, + item: TutorialItem, modifier: Modifier = Modifier, onClick: () -> Unit, ) { diff --git a/apps/flipcash/features/discovery/build.gradle.kts b/apps/flipcash/features/discovery/build.gradle.kts index 5875ff709c..5a38231e3f 100644 --- a/apps/flipcash/features/discovery/build.gradle.kts +++ b/apps/flipcash/features/discovery/build.gradle.kts @@ -7,6 +7,8 @@ android { } dependencies { + implementation(libs.bundles.haze) + implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:shareable")) diff --git a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/TokenDiscoveryScreenContent.kt b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/TokenDiscoveryScreenContent.kt index 72c68f4711..1cd54eaf41 100644 --- a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/TokenDiscoveryScreenContent.kt +++ b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/TokenDiscoveryScreenContent.kt @@ -61,20 +61,17 @@ private fun TokenDiscoveryScreenContent( dispatch: (TokenDiscoveryViewModel.Event) -> Unit ) { val listState = rememberLazyListState() - CodeScaffold { padding -> - AnimatedContent( - targetState = state.tokens, - transitionSpec = { fadeIn(tween()) togetherWith fadeOut(tween()) }, - contentKey = { it::class }, // only crossfade on type change, not data updates - ) { tokens -> - TokenLeaderboard( - category = state.category, - state = listState, - tokens = tokens, - padding = padding, - dispatch = dispatch - ) - } + AnimatedContent( + targetState = state.tokens, + transitionSpec = { fadeIn(tween()) togetherWith fadeOut(tween()) }, + contentKey = { it::class }, // only crossfade on type change, not data updates + ) { tokens -> + TokenLeaderboard( + category = state.category, + state = listState, + tokens = tokens, + dispatch = dispatch + ) } } diff --git a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenLeaderboard.kt b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenLeaderboard.kt index b3bd39014a..20cffbec51 100644 --- a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenLeaderboard.kt +++ b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenLeaderboard.kt @@ -7,18 +7,21 @@ 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.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.FabPosition import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -27,10 +30,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.coerceAtLeast import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.data.Loadable import com.flipcash.app.core.data.isLoaded import com.flipcash.app.discovery.internal.LeaderboardEntry import com.flipcash.app.discovery.internal.TokenDiscoveryViewModel +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.tokens.ui.CurrencyCreatorUpsellCard import com.flipcash.features.discovery.R import com.getcode.manager.BottomBarManager @@ -41,88 +47,70 @@ import com.getcode.ui.core.unboundedClickable import com.getcode.ui.core.verticalScrollStateGradient import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold import com.getcode.ui.utils.sheetResignmentBehavior +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState @Composable internal fun TokenLeaderboard( category: DiscoverCategory?, tokens: Loadable>, - padding: PaddingValues, state: LazyListState, dispatch: (TokenDiscoveryViewModel.Event) -> Unit ) { val reduceBottomPadding = CodeTheme.dimens.grid.x4 - LazyColumn( - modifier = Modifier - .fillMaxSize() - .testTag("discovery_leaderboard") - .verticalScrollStateGradient( - state, - color = CodeTheme.colors.background, - isLongGradient = true, - showAtEnd = false, - ) - .addIf(tokens.isLoaded()) { - Modifier.sheetResignmentBehavior(state) - }, - state = state, - contentPadding = PaddingValues( - start = CodeTheme.dimens.inset, - end = CodeTheme.dimens.inset, - top = CodeTheme.dimens.grid.x2 + padding.calculateTopPadding(), - bottom = (CodeTheme.dimens.grid.x2 + padding.calculateBottomPadding() - reduceBottomPadding).coerceAtLeast( - 0.dp - ) - ) - ) { - when (tokens) { - is Loadable.Error -> { - item { - Box( - modifier = Modifier.fillParentMaxSize(), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = stringResource(R.string.title_discoverFailedToLoad), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - textAlign = TextAlign.Center, - ) - Text( - text = tokens.message.orEmpty(), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - textAlign = TextAlign.Center, - ) - - CodeButton( - onClick = { - dispatch(TokenDiscoveryViewModel.Event.Refresh) - }, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(top = CodeTheme.dimens.grid.x2), - contentPadding = PaddingValues(), - text = stringResource(R.string.action_retry), - shape = CircleShape, - buttonState = ButtonState.Filled - ) - } - } - } - } + val features = LocalFeatureFlags.current + val isNewUi by features.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() - is Loadable.Loaded -> { - val results = tokens.data - // currency creator upsell card - item { - CurrencyCreatorUpsellCard(modifier = Modifier.fillParentMaxWidth()) { - dispatch(TokenDiscoveryViewModel.Event.CreateCurrency) - } - } + // The v2 upsell card sits over the list as the bottom bar; frost it against the leaderboard + // scrolling beneath. The inline (non-v2) card is part of the list itself, so it gets no hazeState. + val hazeState = rememberHazeState() - if (results.isEmpty()) { + val currencyCreatorCard = @Composable { modifier: Modifier, haze: HazeState? -> + CurrencyCreatorUpsellCard(modifier = modifier, hazeState = haze) { + dispatch(TokenDiscoveryViewModel.Event.CreateCurrency) + } + } + CodeScaffold( + bottomBar = { + if (isNewUi) { + currencyCreatorCard( + Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .navigationBarsPadding() + .padding(bottom = CodeTheme.dimens.grid.x3), + hazeState, + ) + } + }, + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .hazeSource(hazeState) + .testTag("discovery_leaderboard") + .verticalScrollStateGradient( + state, + color = CodeTheme.colors.background, + isLongGradient = true, + showAtEnd = isNewUi, + ) + .addIf(tokens.isLoaded()) { + Modifier.sheetResignmentBehavior(state) + }, + state = state, + contentPadding = PaddingValues( + start = CodeTheme.dimens.inset, + end = CodeTheme.dimens.inset, + top = CodeTheme.dimens.grid.x2, + bottom = (CodeTheme.dimens.grid.x2 + padding.calculateBottomPadding() - reduceBottomPadding).coerceAtLeast(0.dp) + ) + ) { + when (tokens) { + is Loadable.Error -> { item { Box( modifier = Modifier.fillParentMaxSize(), @@ -130,97 +118,142 @@ internal fun TokenLeaderboard( ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = when (category) { - DiscoverCategory.Popular -> stringResource(R.string.title_discoverEmptyPopular) - DiscoverCategory.New -> stringResource(R.string.title_discoverEmptyNew) - else -> "" - }, + text = stringResource(R.string.title_discoverFailedToLoad), style = CodeTheme.typography.textLarge, color = CodeTheme.colors.textMain, textAlign = TextAlign.Center, ) Text( - modifier = Modifier.fillMaxWidth(0.6f), - text = when (category) { - DiscoverCategory.Popular -> stringResource(R.string.subtitle_discoverEmptyPopular) - DiscoverCategory.New -> stringResource(R.string.subtitle_discoverEmptyNew) - else -> "" - }, - textAlign = TextAlign.Center, + text = tokens.message.orEmpty(), style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + + CodeButton( + onClick = { + dispatch(TokenDiscoveryViewModel.Event.Refresh) + }, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = CodeTheme.dimens.grid.x2), + contentPadding = PaddingValues(), + text = stringResource(R.string.action_retry), + shape = CircleShape, + buttonState = ButtonState.Filled ) } } } - } else { - // leaderboard header - item { - Row( - modifier = Modifier.padding( - top = CodeTheme.dimens.inset, - bottom = CodeTheme.dimens.grid.x1, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - ) { - Text( - text = stringResource(R.string.title_leaderboard), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - ) + } - Icon( - modifier = Modifier - .size(CodeTheme.dimens.staticGrid.x4) - .unboundedClickable { - dispatch(TokenDiscoveryViewModel.Event.LearnAboutLeaderboard) - }, - imageVector = Icons.Outlined.Info, - tint = CodeTheme.colors.textSecondary, - contentDescription = stringResource(R.string.content_description_leaderboard), - ) - } + is Loadable.Loaded -> { + val results = tokens.data + // currency creator upsell card + if (!isNewUi) { + item { currencyCreatorCard(Modifier.fillParentMaxWidth(), null) } } - itemsIndexed( - items = tokens.data, - key = { _, entry -> entry.key }, - contentType = { _, _ -> "token row" } - ) { index, entry -> - RankedTokenMetricsRow( - modifier = Modifier.padding( - vertical = CodeTheme.dimens.grid.x3, - ), - rank = index + 1, - token = entry.token, - ) { - dispatch(TokenDiscoveryViewModel.Event.OpenTokenInfo(entry.token.address)) + if (results.isEmpty()) { + item { + Box( + modifier = Modifier.fillParentMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = when (category) { + DiscoverCategory.Popular -> stringResource(R.string.title_discoverEmptyPopular) + DiscoverCategory.New -> stringResource(R.string.title_discoverEmptyNew) + else -> "" + }, + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + Text( + modifier = Modifier.fillMaxWidth(0.6f), + text = when (category) { + DiscoverCategory.Popular -> stringResource(R.string.subtitle_discoverEmptyPopular) + DiscoverCategory.New -> stringResource(R.string.subtitle_discoverEmptyNew) + else -> "" + }, + textAlign = TextAlign.Center, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + } } + } else { + // leaderboard header + item { + Row( + modifier = Modifier.padding( + top = CodeTheme.dimens.inset, + bottom = CodeTheme.dimens.grid.x1, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Text( + text = stringResource(R.string.title_leaderboard), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) - if (index < tokens.data.lastIndex) { - HorizontalDivider( - color = CodeTheme.colors.dividerVariant, - modifier = Modifier - .fillMaxWidth() - ) + Icon( + modifier = Modifier + .size(CodeTheme.dimens.staticGrid.x4) + .unboundedClickable { + dispatch(TokenDiscoveryViewModel.Event.LearnAboutLeaderboard) + }, + imageVector = Icons.Outlined.Info, + tint = CodeTheme.colors.textSecondary, + contentDescription = stringResource(R.string.content_description_leaderboard), + ) + } + } + + itemsIndexed( + items = tokens.data, + key = { _, entry -> entry.key }, + contentType = { _, _ -> "token row" } + ) { index, entry -> + RankedTokenMetricsRow( + modifier = Modifier.padding( + vertical = CodeTheme.dimens.grid.x3, + ), + rank = index + 1, + token = entry.token, + ) { + dispatch(TokenDiscoveryViewModel.Event.OpenTokenInfo(entry.token.address)) + } + if (index < tokens.data.lastIndex) { + HorizontalDivider( + color = CodeTheme.colors.dividerVariant, + modifier = Modifier + .fillMaxWidth() + ) + + } } } } - } - is Loadable.Loading -> { - items(12) { index -> - SkeletonRankedTokenMetricsRow( - rank = index + 1, - modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x3) - ) - if (index < 7) { - HorizontalDivider( - color = CodeTheme.colors.dividerVariant, - modifier = Modifier.fillMaxWidth() + is Loadable.Loading -> { + items(12) { index -> + SkeletonRankedTokenMetricsRow( + rank = index + 1, + modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x3) ) + if (index < 7) { + HorizontalDivider( + color = CodeTheme.colors.dividerVariant, + modifier = Modifier.fillMaxWidth() + ) + } } } } diff --git a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenMetricsRow.kt b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenMetricsRow.kt index 0e15a9e8bc..1f64ba245b 100644 --- a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenMetricsRow.kt +++ b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenMetricsRow.kt @@ -36,12 +36,17 @@ import com.getcode.ui.core.addIf import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.padding +sealed interface RankingSystem { + object Holders : RankingSystem + object MarketCap : RankingSystem +} @Composable internal fun RankedTokenMetricsRow( rank: Int, token: Token, modifier: Modifier = Modifier, + rankingSystem: RankingSystem = RankingSystem.Holders, onClick: () -> Unit, ) { Row( @@ -52,7 +57,7 @@ internal fun RankedTokenMetricsRow( verticalAlignment = Alignment.CenterVertically ) { RankBadge(rank) - TokenMetricsRow(token = token, onClick = null) + TokenMetricsRow(token = token, rankingSystem = rankingSystem, onClick = null) } } @@ -61,6 +66,67 @@ internal fun TokenMetricsRow( token: Token, modifier: Modifier = Modifier, window: WindowedRange = WindowedRange.LastWeek, + rankingSystem: RankingSystem = RankingSystem.Holders, + onClick: (() -> Unit)? = null, +) { + when (rankingSystem) { + RankingSystem.Holders -> { + val metricsDelta = + remember(token.holderMetrics) { token.holderMetrics.deltaForWindow(window) } + val change = if (metricsDelta >= 0) LineTrend.Up else LineTrend.Down + val deltaForWindow = buildString { + when (change) { + LineTrend.Down -> Unit // negative carried over from abbreviated + LineTrend.Up -> append("+") + } + append(metricsDelta.abbreviated()) + append(" ") + append( + when (window) { + WindowedRange.AllTime -> stringResource(R.string.label_marketCapAllTime) + WindowedRange.LastDay -> stringResource(R.string.label_marketCapDay) + WindowedRange.LastWeek -> stringResource(R.string.label_marketCapWeek) + WindowedRange.LastMonth -> stringResource(R.string.label_marketCapMonth) + WindowedRange.LastYear -> stringResource(R.string.label_marketCapYear) + } + ) + } + + val subtitle = token.marketCap()?.formatted().orEmpty() + + val value = pluralStringResource( + R.plurals.subtitle_personCount, + token.holderMetrics.currentHolders.toInt(), + token.holderMetrics.currentHolders.abbreviated() + ) + + TokenMetricsRow( + modifier = modifier, + token = token, + subtitle = subtitle, + value = value, + valueChange = deltaForWindow, + valueChangeColor = when (change) { + LineTrend.Down -> CodeTheme.colors.textSecondary + LineTrend.Up -> change.color + }, + onClick = onClick, + ) + } + RankingSystem.MarketCap -> { + // TODO: once we have market cap metrics cross window + } + } +} + +@Composable +private fun TokenMetricsRow( + token: Token, + subtitle: String, + value: String, + valueChange: String, + valueChangeColor: Color, + modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, ) { Row( @@ -92,11 +158,7 @@ internal fun TokenMetricsRow( ) Text( modifier = Modifier.alignByBaseline(), - text = pluralStringResource( - R.plurals.subtitle_personCount, - token.holderMetrics.currentHolders.toInt(), - token.holderMetrics.currentHolders.abbreviated() - ), + text = value, color = CodeTheme.colors.textMain, style = CodeTheme.typography.textMedium, ) @@ -107,38 +169,15 @@ internal fun TokenMetricsRow( ) { Text( modifier = Modifier.alignByBaseline(), - text = token.marketCap()?.formatted().orEmpty(), + text = subtitle, color = CodeTheme.colors.textSecondary, style = CodeTheme.typography.caption, ) - val metricsDelta = - remember(token.holderMetrics) { token.holderMetrics.deltaForWindow(window) } - val change = if (metricsDelta >= 0) LineTrend.Up else LineTrend.Down - val deltaForWindow = buildString { - when (change) { - LineTrend.Down -> Unit // negative carried over from abbreviated - LineTrend.Up -> append("+") - } - append(metricsDelta.abbreviated()) - append(" ") - append( - when (window) { - WindowedRange.AllTime -> stringResource(R.string.label_marketCapAllTime) - WindowedRange.LastDay -> stringResource(R.string.label_marketCapDay) - WindowedRange.LastWeek -> stringResource(R.string.label_marketCapWeek) - WindowedRange.LastMonth -> stringResource(R.string.label_marketCapMonth) - WindowedRange.LastYear -> stringResource(R.string.label_marketCapYear) - } - ) - } Text( modifier = Modifier.alignByBaseline(), - text = deltaForWindow, - color = when (change) { - LineTrend.Down -> CodeTheme.colors.textSecondary - LineTrend.Up -> change.color - }, + text = valueChange, + color = valueChangeColor, style = CodeTheme.typography.caption, ) } diff --git a/apps/flipcash/shared/tokens/build.gradle.kts b/apps/flipcash/shared/tokens/build.gradle.kts index c13c49b28e..471951ac69 100644 --- a/apps/flipcash/shared/tokens/build.gradle.kts +++ b/apps/flipcash/shared/tokens/build.gradle.kts @@ -18,6 +18,8 @@ dependencies { implementation(libs.androidx.lifecycle.process) + implementation(libs.bundles.haze) + implementation(project(":apps:flipcash:shared:amount-entry")) implementation(project(":apps:flipcash:shared:funding")) implementation(project(":apps:flipcash:shared:transaction-history")) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CurrencyCreatorUpsellCard.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CurrencyCreatorUpsellCard.kt index 19f74d7267..0259132827 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CurrencyCreatorUpsellCard.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/CurrencyCreatorUpsellCard.kt @@ -1,6 +1,7 @@ package com.flipcash.app.tokens.ui import androidx.compose.foundation.Image +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -16,6 +17,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -27,17 +31,45 @@ import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.app.theme.MultiDevicePreview import com.flipcash.shared.tokens.R import com.getcode.theme.CodeTheme +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.HazeBlurStyle +import dev.chrisbanes.haze.blur.HazeColorEffect +import dev.chrisbanes.haze.blur.blurEffect +import dev.chrisbanes.haze.hazeEffect @Composable fun CurrencyCreatorUpsellCard( modifier: Modifier = Modifier, + hazeState: HazeState? = null, onClick: () -> Unit, ) { + val shape = CodeTheme.shapes.medium + + // When a HazeState is supplied the card frosts whatever list content scrolls beneath it (iOS + // "liquid glass"), matching the v2 navigation pill: a wide blur plus a strong tint toward the + // BACKGROUND colour at high alpha, finished with a faint bright rim. `clip` must precede + // `hazeEffect` so the blur is bounded to the rounded card, not its bounding box. Falls back to the + // opaque surface when no HazeState is supplied (e.g. when the card is itself part of the list). + val glassTint = lerp(CodeTheme.colors.background, Color.White, 0.18f) + val liquidGlass = HazeBlurStyle( + blurRadius = 32.dp, + backgroundColor = CodeTheme.colors.background, + colorEffect = HazeColorEffect.tint(glassTint.copy(alpha = 0.72f)), + ) + val glassBackground = if (hazeState != null) { + Modifier + .clip(shape) + .hazeEffect(hazeState) { blurEffect { style = liquidGlass } } + .border(CodeTheme.dimens.border, Color.White.copy(alpha = 0.08f), shape) + } else { + Modifier + } + Surface( - modifier = modifier, - color = CodeTheme.colors.surfaceVariant, + modifier = modifier.then(glassBackground), + color = if (hazeState != null) Color.Transparent else CodeTheme.colors.surfaceVariant, contentColor = CodeTheme.colors.textMain, - shape = CodeTheme.shapes.medium, + shape = shape, tonalElevation = 0.dp, shadowElevation = 0.dp, onClick = onClick diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt index 4b36bae688..0526beeb66 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt @@ -137,11 +137,7 @@ class SelectTokenViewModel @Inject constructor( appreciation = appreciation, displayName = when (purpose) { TokenPurpose.Balance -> { - if (it.token.address == Mint.usdf && featureFlags.get(FeatureFlag.NewUi)) { - resources.getString(R.string.displayName_dollars) - } else { - it.token.name - } + it.token.name } is TokenPurpose.Swap, @@ -151,7 +147,7 @@ class SelectTokenViewModel @Inject constructor( TokenPurpose.Withdraw -> { if (it.token.address == Mint.usdf) { if (featureFlags.get(FeatureFlag.NewUi)) { - resources.getString(R.string.displayName_dollars) + it.token.name } else { resources.getString(R.string.displayName_usdf) }