diff --git a/.claude/skills/fetch-protos/SKILL.md b/.claude/skills/fetch-protos/SKILL.md index c38b4fbac..c7984f902 100644 --- a/.claude/skills/fetch-protos/SKILL.md +++ b/.claude/skills/fetch-protos/SKILL.md @@ -87,6 +87,8 @@ Only build the targets that were fetched. If the build fails, show errors and st ### Step 4 — Detect service layer impact +#### 4a — RPC changes + For each new or modified RPC found in Step 2: 1. Identify which service proto file it belongs to (e.g., `account/v1/flipcash_account_service.proto`) @@ -101,6 +103,59 @@ Present a report: | `NewRpc` | missing | missing | missing | missing | **New — needs scaffolding** | | `ModifiedRpc` | exists | exists | exists | exists | **Signature may need update** | +#### 4b — Message field changes (domain models) + +For each message with added or removed fields (e.g., `UserFlags`, `UserProfile`): + +1. Search for the corresponding domain model in `services//src/**/models/` +2. Search for the corresponding mapper in `services//src/**/internal/domain/` +3. For **added fields**: add the property to the domain data class, set a sensible + default in the `Default` companion, and map it in the mapper +4. For **removed fields**: remove the property from the domain data class, its + default, and the mapper line + +**Common type mappings** (match existing fields on the same model): + +| Proto type | Domain type | Mapping | +|------------|-------------|---------| +| `uint64` amount/quarks fields | `Fiat` | `Fiat(quarks = from.fieldName)` | +| `bool` | `Boolean` | `from.fieldName` | +| `string` | `String` | `from.fieldName` | +| `int32`/`uint32` | `Int` | `from.fieldName` | +| `Duration` | `kotlin.time.Duration` | `from.fieldName.seconds.toDuration(DurationUnit.SECONDS)` | +| `enum` | sealed/enum domain type | `from.fieldName.toDomain()` (add private extension) | + +When in doubt, look at how neighboring fields on the same message are typed and +mapped — follow the same pattern. + +##### UserFlags-specific chain + +When `UserFlags` fields change, the following files form a chain that must all be +updated together. Ask the user whether the new field should be **read-only** (display +only) or **editable** (overridable via the debug editor). + +| # | File | What to update | +|---|------|----------------| +| 1 | `services/flipcash/src/**/models/UserFlags.kt` | Add/remove property + `Default` companion value | +| 2 | `services/flipcash/src/**/internal/domain/UserFlagsMapper.kt` | Add/remove mapping line in `map()` | +| 3 | `apps/flipcash/shared/userflags/src/**/ResolvedUserFlags.kt` | Add/remove `ResolvedFlag` property + line in `resolve()` extension | +| 4 | `apps/flipcash/features/userflags/src/**/internal/UserFlagsViewModel.kt` | Add to `readOnlyEntries` (bool) or `editableEntries()` list | + +If the field is **editable** (overridable), also update: + +| # | File | What to update | +|---|------|----------------| +| 5 | `apps/flipcash/shared/userflags/src/**/UserFlagsCoordinator.kt` | Add `FieldOverride` to `Overrides` data class + `Overrides.None` + `overrides` flow mapping | +| 6 | `apps/flipcash/shared/userflags/src/**/Field.kt` | Add `data object` subclass with preference key, encode/decode, label, editor | +| 7 | `apps/flipcash/shared/userflags/src/main/res/values/strings.xml` | Add `label_flag_*` (and `hint_flag_*` if needed) string resources | + +For **read-only** fields (e.g., booleans like `enablePhoneNumberSend`): +- In `ResolvedUserFlags.resolve()`, use `FieldOverride.None` (no override support) +- In `UserFlagsViewModel`, add to `readOnlyEntries` with a string resource label +- No changes needed in `Overrides`, `Field.kt`, or `UserFlagsCoordinator` + +Present a report of domain model updates needed and apply them after user confirmation. + ### Step 5 — Scaffold new service stubs For RPCs marked as needing scaffolding, ask the user if they want to scaffold them. diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index eb62ea2e3..b6ac480e9 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -689,4 +689,8 @@ Deposit Other Flipcash Currencies Withdraw Other Flipcash Currencies + + Learn more + Leaderboard Ranking + People must have a minimum balance of %1$s to be counted \ No newline at end of file diff --git a/apps/flipcash/features/discovery/build.gradle.kts b/apps/flipcash/features/discovery/build.gradle.kts index cae1246e0..5a38231e3 100644 --- a/apps/flipcash/features/discovery/build.gradle.kts +++ b/apps/flipcash/features/discovery/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:shareable")) implementation(project(":apps:flipcash:shared:tokens")) + implementation(project(":apps:flipcash:shared:userflags")) implementation(project(":libs:datetime")) implementation(project(":libs:messaging")) diff --git a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/TokenDiscoveryViewModel.kt b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/TokenDiscoveryViewModel.kt index 355333f29..95a481502 100644 --- a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/TokenDiscoveryViewModel.kt +++ b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/TokenDiscoveryViewModel.kt @@ -5,6 +5,7 @@ import com.flipcash.app.core.data.Loadable import com.flipcash.app.core.extensions.onResult import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.features.discovery.R import com.getcode.opencode.controllers.CurrencyController import com.getcode.opencode.model.financial.Token @@ -12,6 +13,9 @@ import com.getcode.opencode.model.ui.DiscoverCategory import com.getcode.solana.keys.Mint import com.getcode.util.resources.ResourceHelper import com.flipcash.libs.coroutines.DispatcherProvider +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.toFiat import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay @@ -25,6 +29,7 @@ import javax.inject.Inject @HiltViewModel internal class TokenDiscoveryViewModel @Inject constructor( private val currencyController: CurrencyController, + private val userFlags: UserFlagsCoordinator, private val resources: ResourceHelper, featureFlags: FeatureFlagController, dispatchers: DispatcherProvider, @@ -38,10 +43,13 @@ internal class TokenDiscoveryViewModel @Inject constructor( val createEnabled: Boolean = false, val category: DiscoverCategory? = null, val tokens: Loadable> = Loadable.Loading(), + val minimumHolderAmount: Fiat = 10.toFiat(), ) sealed interface Event { data class OnCreateAllowed(val enabled: Boolean) : Event + data class OnMinimumHolderAmountChanged(val amount: Fiat): Event + data object LearnAboutLeaderboard: Event data class OnCategorySelected( val category: DiscoverCategory, val fromUser: Boolean = false @@ -56,6 +64,11 @@ internal class TokenDiscoveryViewModel @Inject constructor( } init { + userFlags.resolvedFlags + .map { it.minimumHolderAmountForLeaderboard.effectiveValue } + .onEach { dispatchEvent(Event.OnMinimumHolderAmountChanged(it)) } + .launchIn(viewModelScope) + featureFlags.observe(FeatureFlag.CurrencyCreator) .onEach { dispatchEvent(Event.OnCreateAllowed(it)) } .launchIn(viewModelScope) @@ -100,7 +113,19 @@ internal class TokenDiscoveryViewModel @Inject constructor( ) .launchIn(viewModelScope) - + eventFlow + .filterIsInstance() + .map { stateFlow.value.minimumHolderAmount } + .onEach { amount -> + val minAmount = amount.formatted( + rule = Fiat.FormattingRule.Truncated, + suffix = resources.getString(R.string.subtitle_usdSuffix), + ) + BottomBarManager.showInfo( + title = resources.getString(R.string.prompt_title_learnAboutLeaderboard), + message = resources.getString(R.string.prompt_description_learnAboutLeaderboard, minAmount) + ) + }.launchIn(viewModelScope) } internal companion object { @@ -110,6 +135,10 @@ internal class TokenDiscoveryViewModel @Inject constructor( state.copy(category = event.category) } + is Event.OnMinimumHolderAmountChanged -> { state -> + state.copy(minimumHolderAmount = event.amount) + } + is Event.OnCreateAllowed -> { state -> state.copy(createEnabled = event.enabled) } @@ -122,6 +151,7 @@ internal class TokenDiscoveryViewModel @Inject constructor( is Event.OpenTokenInfo -> { state -> state } is Event.Refresh -> { state -> state } is Event.CreateCurrency -> { state -> state } + Event.LearnAboutLeaderboard -> { state -> state } } } } 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 9e83a5356..4f5185409 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 @@ -1,20 +1,27 @@ package com.flipcash.app.discovery.internal.components +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.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.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.coerceAtLeast @@ -24,11 +31,13 @@ import com.flipcash.app.core.data.isLoaded import com.flipcash.app.discovery.internal.TokenDiscoveryViewModel import com.flipcash.app.tokens.ui.CurrencyCreatorUpsellCard import com.flipcash.features.discovery.R +import com.getcode.manager.BottomBarManager import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.ui.DiscoverCategory import com.getcode.solana.keys.base58 import com.getcode.theme.CodeTheme import com.getcode.ui.core.addIf +import com.getcode.ui.core.unboundedClickable import com.getcode.ui.core.verticalScrollStateGradient import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton @@ -61,7 +70,9 @@ internal fun TokenLeaderboard( 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) + bottom = (CodeTheme.dimens.grid.x2 + padding.calculateBottomPadding() - reduceBottomPadding).coerceAtLeast( + 0.dp + ) ) ) { when (tokens) { @@ -145,15 +156,31 @@ internal fun TokenLeaderboard( } else { // leaderboard header item { - Text( + Row( modifier = Modifier.padding( top = CodeTheme.dimens.inset, bottom = CodeTheme.dimens.grid.x1, ), - text = stringResource(R.string.title_leaderboard), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - ) + 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), + ) + } } itemsIndexed( diff --git a/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsViewModel.kt b/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsViewModel.kt index 9ebf55f59..20ecdb9da 100644 --- a/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsViewModel.kt +++ b/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsViewModel.kt @@ -141,4 +141,5 @@ private fun ResolvedUserFlags.editableEntries(): List> = listOf EditableEntry(Field.NewCurrencyFeeAmount, newCurrencyFeeAmount), EditableEntry(Field.WithdrawalFeeAmount, withdrawalFeeAmount), EditableEntry(Field.PreferredUsdcOnRampLiquidityPool, usdcOnRampLiquidityPool), + EditableEntry(Field.MinimumHolderAmountForLeaderboard, minimumHolderAmountForLeaderboard), ) \ No newline at end of file diff --git a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/Field.kt b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/Field.kt index 181d2a4cc..fdb6e4df8 100644 --- a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/Field.kt +++ b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/Field.kt @@ -169,6 +169,25 @@ sealed class Field( ) ), ) + + data object MinimumHolderAmountForLeaderboard : Field( + longPreferencesKey("override_min_holder_amount"), + encode = { it.quarks }, + decode = { Fiat(quarks = it) }, + label = R.string.label_flag_minHolderAmount, + hint = R.string.hint_flag_minHolderAmount, + format = { it.formatted(rule = Fiat.FormattingRule.Truncated) }, + editFormat = { it.formatted(showPrefix = false, rule = Fiat.FormattingRule.Truncated) }, + editor = FieldEditor.TextInput( + keyboard = KeyboardType.Decimal, + parse = { it.toDoubleOrNull()?.let { d -> Fiat(fiat = d) } }, + ), + outputTransformation = OutputTransformation { + if (length > 0) { + insert(0, "$") + } + }, + ) } internal fun OnRampProvider.Defined.encode(): String = when (this) { diff --git a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt index 42903fc14..e0242d185 100644 --- a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt +++ b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt @@ -32,6 +32,7 @@ data class ResolvedUserFlags( val withdrawalFeeAmount: ResolvedFlag, val usdcOnRampLiquidityPool: ResolvedFlag, val enablePhoneNumberSend: ResolvedFlag, + val minimumHolderAmountForLeaderboard: ResolvedFlag, ) internal fun UserFlags.resolve(overrides: Overrides): ResolvedUserFlags = ResolvedUserFlags( @@ -47,4 +48,5 @@ internal fun UserFlags.resolve(overrides: Overrides): ResolvedUserFlags = Resolv withdrawalFeeAmount = ResolvedFlag(withdrawalFeeAmount, overrides.withdrawalFeeAmount), usdcOnRampLiquidityPool = ResolvedFlag(preferredUsdcOnRampLiquidityPool, overrides.preferredUsdcOnRampLiquidityPool), enablePhoneNumberSend = ResolvedFlag(enablePhoneNumberSend, FieldOverride.None), + minimumHolderAmountForLeaderboard = ResolvedFlag(minimumHolderValue, overrides.minimumHolderAmountForLeaderboard), ) \ No newline at end of file diff --git a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt index 4895d29c6..0d28822da 100644 --- a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt +++ b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt @@ -29,7 +29,7 @@ import kotlin.time.Duration @Singleton class UserFlagsCoordinator @Inject constructor( - @ApplicationContext private val context: Context, + @param:ApplicationContext private val context: Context, userManager: UserManager, dispatchers: DispatcherProvider, ) { @@ -42,6 +42,7 @@ class UserFlagsCoordinator @Inject constructor( val newCurrencyFeeAmount: FieldOverride, val withdrawalFeeAmount: FieldOverride, val preferredUsdcOnRampLiquidityPool: FieldOverride, + val minimumHolderAmountForLeaderboard: FieldOverride, ) { companion object { val None = Overrides( @@ -53,6 +54,7 @@ class UserFlagsCoordinator @Inject constructor( newCurrencyFeeAmount = FieldOverride.None, withdrawalFeeAmount = FieldOverride.None, preferredUsdcOnRampLiquidityPool = FieldOverride.None, + minimumHolderAmountForLeaderboard = FieldOverride.None, ) } } @@ -86,6 +88,7 @@ class UserFlagsCoordinator @Inject constructor( newCurrencyFeeAmount = prefs.readOverride(Field.NewCurrencyFeeAmount), withdrawalFeeAmount = prefs.readOverride(Field.WithdrawalFeeAmount), preferredUsdcOnRampLiquidityPool = prefs.readOverride(Field.PreferredUsdcOnRampLiquidityPool), + minimumHolderAmountForLeaderboard = prefs.readOverride(Field.MinimumHolderAmountForLeaderboard), ) }.stateIn(scope, SharingStarted.Eagerly, Overrides.None) diff --git a/apps/flipcash/shared/userflags/src/main/res/values/strings.xml b/apps/flipcash/shared/userflags/src/main/res/values/strings.xml index 2ac0fba1d..c512baba7 100644 --- a/apps/flipcash/shared/userflags/src/main/res/values/strings.xml +++ b/apps/flipcash/shared/userflags/src/main/res/values/strings.xml @@ -18,4 +18,6 @@ Enter amount Preferred USDC On-Ramp Liquidity Pool Phone Number Send Enabled + Minimum Holder Amount for Leaderboard + Enter amount \ No newline at end of file diff --git a/definitions/flipcash/protos/src/main/proto/account/v1/flipcash_account_service.proto b/definitions/flipcash/protos/src/main/proto/account/v1/flipcash_account_service.proto index 3e8f225a2..801393ca9 100644 --- a/definitions/flipcash/protos/src/main/proto/account/v1/flipcash_account_service.proto +++ b/definitions/flipcash/protos/src/main/proto/account/v1/flipcash_account_service.proto @@ -160,4 +160,7 @@ message UserFlags { // Whether the send by phone number feature is enabled bool enable_phone_number_send = 12; + + // USDF amount, in quarks, that a user must hold to be counted as a holder on the leaderboard + uint64 minimum_holder_value = 13; } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserFlagsMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserFlagsMapper.kt index f4f123caf..88874be84 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserFlagsMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserFlagsMapper.kt @@ -27,6 +27,7 @@ internal class UserFlagsMapper @Inject constructor(): withdrawalFeeAmount = Fiat(quarks = from.withdrawalFeeAmount), preferredUsdcOnRampLiquidityPool = from.preferredOnRampUsdcLiquidityPool.toDomain(), enablePhoneNumberSend = from.enablePhoneNumberSend, + minimumHolderValue = Fiat(quarks = from.minimumHolderValue), ) } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserFlags.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserFlags.kt index 6a8478780..2d19bd32b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserFlags.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserFlags.kt @@ -18,6 +18,7 @@ data class UserFlags( val withdrawalFeeAmount: Fiat, val preferredUsdcOnRampLiquidityPool: UsdcLiquidtyPool, val enablePhoneNumberSend: Boolean, + val minimumHolderValue: Fiat, ) { companion object { val Default = UserFlags( @@ -33,6 +34,7 @@ data class UserFlags( withdrawalFeeAmount = Fiat.Zero, preferredUsdcOnRampLiquidityPool = UsdcLiquidtyPool.Unknown, enablePhoneNumberSend = false, + minimumHolderValue = Fiat.Zero, ) } } \ No newline at end of file