Skip to content

Commit e635627

Browse files
authored
feat(discovery): show leaderboard minimum holder amount with info prompt (#779)
* feat(protos): add minimum_holder_value to UserFlags and wire through userflag editor Update flipcash protobuf definitions adding minimum_holder_value (uint64, field 13) to UserFlags. Wire the new field through the full UserFlags chain: domain model, mapper, ResolvedUserFlags, UserFlagsCoordinator overrides, and Field editor. Signed-off-by: Brandon McAnsh <git@bmcreations.dev> * feat(discovery): show leaderboard minimum holder amount with info prompt Wire minimumHolderAmountForLeaderboard from resolved user flags into TokenDiscoveryViewModel. Add info icon on leaderboard header that shows the minimum hold amount required to appear on the leaderboard. Signed-off-by: Brandon McAnsh <git@bmcreations.dev> * chore(userflags): make min holder amount editable for UI testing Signed-off-by: Brandon McAnsh <git@bmcreations.dev> --------- Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent cfe2964 commit e635627

13 files changed

Lines changed: 158 additions & 8 deletions

File tree

.claude/skills/fetch-protos/SKILL.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ Only build the targets that were fetched. If the build fails, show errors and st
8787

8888
### Step 4 — Detect service layer impact
8989

90+
#### 4a — RPC changes
91+
9092
For each new or modified RPC found in Step 2:
9193

9294
1. Identify which service proto file it belongs to (e.g., `account/v1/flipcash_account_service.proto`)
@@ -101,6 +103,59 @@ Present a report:
101103
| `NewRpc` | missing | missing | missing | missing | **New — needs scaffolding** |
102104
| `ModifiedRpc` | exists | exists | exists | exists | **Signature may need update** |
103105

106+
#### 4b — Message field changes (domain models)
107+
108+
For each message with added or removed fields (e.g., `UserFlags`, `UserProfile`):
109+
110+
1. Search for the corresponding domain model in `services/<target>/src/**/models/`
111+
2. Search for the corresponding mapper in `services/<target>/src/**/internal/domain/`
112+
3. For **added fields**: add the property to the domain data class, set a sensible
113+
default in the `Default` companion, and map it in the mapper
114+
4. For **removed fields**: remove the property from the domain data class, its
115+
default, and the mapper line
116+
117+
**Common type mappings** (match existing fields on the same model):
118+
119+
| Proto type | Domain type | Mapping |
120+
|------------|-------------|---------|
121+
| `uint64` amount/quarks fields | `Fiat` | `Fiat(quarks = from.fieldName)` |
122+
| `bool` | `Boolean` | `from.fieldName` |
123+
| `string` | `String` | `from.fieldName` |
124+
| `int32`/`uint32` | `Int` | `from.fieldName` |
125+
| `Duration` | `kotlin.time.Duration` | `from.fieldName.seconds.toDuration(DurationUnit.SECONDS)` |
126+
| `enum` | sealed/enum domain type | `from.fieldName.toDomain()` (add private extension) |
127+
128+
When in doubt, look at how neighboring fields on the same message are typed and
129+
mapped — follow the same pattern.
130+
131+
##### UserFlags-specific chain
132+
133+
When `UserFlags` fields change, the following files form a chain that must all be
134+
updated together. Ask the user whether the new field should be **read-only** (display
135+
only) or **editable** (overridable via the debug editor).
136+
137+
| # | File | What to update |
138+
|---|------|----------------|
139+
| 1 | `services/flipcash/src/**/models/UserFlags.kt` | Add/remove property + `Default` companion value |
140+
| 2 | `services/flipcash/src/**/internal/domain/UserFlagsMapper.kt` | Add/remove mapping line in `map()` |
141+
| 3 | `apps/flipcash/shared/userflags/src/**/ResolvedUserFlags.kt` | Add/remove `ResolvedFlag<T>` property + line in `resolve()` extension |
142+
| 4 | `apps/flipcash/features/userflags/src/**/internal/UserFlagsViewModel.kt` | Add to `readOnlyEntries` (bool) or `editableEntries()` list |
143+
144+
If the field is **editable** (overridable), also update:
145+
146+
| # | File | What to update |
147+
|---|------|----------------|
148+
| 5 | `apps/flipcash/shared/userflags/src/**/UserFlagsCoordinator.kt` | Add `FieldOverride<T>` to `Overrides` data class + `Overrides.None` + `overrides` flow mapping |
149+
| 6 | `apps/flipcash/shared/userflags/src/**/Field.kt` | Add `data object` subclass with preference key, encode/decode, label, editor |
150+
| 7 | `apps/flipcash/shared/userflags/src/main/res/values/strings.xml` | Add `label_flag_*` (and `hint_flag_*` if needed) string resources |
151+
152+
For **read-only** fields (e.g., booleans like `enablePhoneNumberSend`):
153+
- In `ResolvedUserFlags.resolve()`, use `FieldOverride.None` (no override support)
154+
- In `UserFlagsViewModel`, add to `readOnlyEntries` with a string resource label
155+
- No changes needed in `Overrides`, `Field.kt`, or `UserFlagsCoordinator`
156+
157+
Present a report of domain model updates needed and apply them after user confirmation.
158+
104159
### Step 5 — Scaffold new service stubs
105160

106161
For RPCs marked as needing scaffolding, ask the user if they want to scaffold them.

apps/flipcash/core/src/main/res/values/strings.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,4 +689,8 @@
689689

690690
<string name="action_depositOtherCurrencies">Deposit Other Flipcash Currencies</string>
691691
<string name="action_withdrawOtherCurrencies">Withdraw Other Flipcash Currencies</string>
692+
693+
<string name="content_description_leaderboard">Learn more</string>
694+
<string name="prompt_title_learnAboutLeaderboard">Leaderboard Ranking</string>
695+
<string name="prompt_description_learnAboutLeaderboard">People must have a minimum balance of %1$s to be counted</string>
692696
</resources>

apps/flipcash/features/discovery/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dependencies {
1313
implementation(project(":apps:flipcash:shared:featureflags"))
1414
implementation(project(":apps:flipcash:shared:shareable"))
1515
implementation(project(":apps:flipcash:shared:tokens"))
16+
implementation(project(":apps:flipcash:shared:userflags"))
1617

1718
implementation(project(":libs:datetime"))
1819
implementation(project(":libs:messaging"))

apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/TokenDiscoveryViewModel.kt

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@ import com.flipcash.app.core.data.Loadable
55
import com.flipcash.app.core.extensions.onResult
66
import com.flipcash.app.featureflags.FeatureFlag
77
import com.flipcash.app.featureflags.FeatureFlagController
8+
import com.flipcash.app.userflags.UserFlagsCoordinator
89
import com.flipcash.features.discovery.R
910
import com.getcode.opencode.controllers.CurrencyController
1011
import com.getcode.opencode.model.financial.Token
1112
import com.getcode.opencode.model.ui.DiscoverCategory
1213
import com.getcode.solana.keys.Mint
1314
import com.getcode.util.resources.ResourceHelper
1415
import com.flipcash.libs.coroutines.DispatcherProvider
16+
import com.getcode.manager.BottomBarManager
17+
import com.getcode.opencode.model.financial.Fiat
18+
import com.getcode.opencode.model.financial.toFiat
1519
import com.getcode.view.BaseViewModel
1620
import dagger.hilt.android.lifecycle.HiltViewModel
1721
import kotlinx.coroutines.delay
@@ -25,6 +29,7 @@ import javax.inject.Inject
2529
@HiltViewModel
2630
internal class TokenDiscoveryViewModel @Inject constructor(
2731
private val currencyController: CurrencyController,
32+
private val userFlags: UserFlagsCoordinator,
2833
private val resources: ResourceHelper,
2934
featureFlags: FeatureFlagController,
3035
dispatchers: DispatcherProvider,
@@ -38,10 +43,13 @@ internal class TokenDiscoveryViewModel @Inject constructor(
3843
val createEnabled: Boolean = false,
3944
val category: DiscoverCategory? = null,
4045
val tokens: Loadable<List<Token>> = Loadable.Loading(),
46+
val minimumHolderAmount: Fiat = 10.toFiat(),
4147
)
4248

4349
sealed interface Event {
4450
data class OnCreateAllowed(val enabled: Boolean) : Event
51+
data class OnMinimumHolderAmountChanged(val amount: Fiat): Event
52+
data object LearnAboutLeaderboard: Event
4553
data class OnCategorySelected(
4654
val category: DiscoverCategory,
4755
val fromUser: Boolean = false
@@ -56,6 +64,11 @@ internal class TokenDiscoveryViewModel @Inject constructor(
5664
}
5765

5866
init {
67+
userFlags.resolvedFlags
68+
.map { it.minimumHolderAmountForLeaderboard.effectiveValue }
69+
.onEach { dispatchEvent(Event.OnMinimumHolderAmountChanged(it)) }
70+
.launchIn(viewModelScope)
71+
5972
featureFlags.observe(FeatureFlag.CurrencyCreator)
6073
.onEach { dispatchEvent(Event.OnCreateAllowed(it)) }
6174
.launchIn(viewModelScope)
@@ -100,7 +113,19 @@ internal class TokenDiscoveryViewModel @Inject constructor(
100113
)
101114
.launchIn(viewModelScope)
102115

103-
116+
eventFlow
117+
.filterIsInstance<Event.LearnAboutLeaderboard>()
118+
.map { stateFlow.value.minimumHolderAmount }
119+
.onEach { amount ->
120+
val minAmount = amount.formatted(
121+
rule = Fiat.FormattingRule.Truncated,
122+
suffix = resources.getString(R.string.subtitle_usdSuffix),
123+
)
124+
BottomBarManager.showInfo(
125+
title = resources.getString(R.string.prompt_title_learnAboutLeaderboard),
126+
message = resources.getString(R.string.prompt_description_learnAboutLeaderboard, minAmount)
127+
)
128+
}.launchIn(viewModelScope)
104129
}
105130

106131
internal companion object {
@@ -110,6 +135,10 @@ internal class TokenDiscoveryViewModel @Inject constructor(
110135
state.copy(category = event.category)
111136
}
112137

138+
is Event.OnMinimumHolderAmountChanged -> { state ->
139+
state.copy(minimumHolderAmount = event.amount)
140+
}
141+
113142
is Event.OnCreateAllowed -> { state ->
114143
state.copy(createEnabled = event.enabled)
115144
}
@@ -122,6 +151,7 @@ internal class TokenDiscoveryViewModel @Inject constructor(
122151
is Event.OpenTokenInfo -> { state -> state }
123152
is Event.Refresh -> { state -> state }
124153
is Event.CreateCurrency -> { state -> state }
154+
Event.LearnAboutLeaderboard -> { state -> state }
125155
}
126156
}
127157
}

apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenLeaderboard.kt

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
package com.flipcash.app.discovery.internal.components
22

3+
import androidx.compose.foundation.layout.Arrangement
34
import androidx.compose.foundation.layout.Box
45
import androidx.compose.foundation.layout.Column
56
import androidx.compose.foundation.layout.PaddingValues
7+
import androidx.compose.foundation.layout.Row
68
import androidx.compose.foundation.layout.fillMaxSize
79
import androidx.compose.foundation.layout.fillMaxWidth
810
import androidx.compose.foundation.layout.padding
11+
import androidx.compose.foundation.layout.size
912
import androidx.compose.foundation.lazy.LazyColumn
1013
import androidx.compose.foundation.lazy.LazyListState
1114
import androidx.compose.foundation.lazy.itemsIndexed
1215
import androidx.compose.foundation.shape.CircleShape
16+
import androidx.compose.material.icons.Icons
17+
import androidx.compose.material.icons.outlined.Info
1318
import androidx.compose.material3.HorizontalDivider
19+
import androidx.compose.material3.Icon
1420
import androidx.compose.material3.Text
1521
import androidx.compose.runtime.Composable
1622
import androidx.compose.ui.Alignment
1723
import androidx.compose.ui.Modifier
24+
import androidx.compose.ui.res.painterResource
1825
import androidx.compose.ui.res.stringResource
1926
import androidx.compose.ui.text.style.TextAlign
2027
import androidx.compose.ui.unit.coerceAtLeast
@@ -24,11 +31,13 @@ import com.flipcash.app.core.data.isLoaded
2431
import com.flipcash.app.discovery.internal.TokenDiscoveryViewModel
2532
import com.flipcash.app.tokens.ui.CurrencyCreatorUpsellCard
2633
import com.flipcash.features.discovery.R
34+
import com.getcode.manager.BottomBarManager
2735
import com.getcode.opencode.model.financial.Token
2836
import com.getcode.opencode.model.ui.DiscoverCategory
2937
import com.getcode.solana.keys.base58
3038
import com.getcode.theme.CodeTheme
3139
import com.getcode.ui.core.addIf
40+
import com.getcode.ui.core.unboundedClickable
3241
import com.getcode.ui.core.verticalScrollStateGradient
3342
import com.getcode.ui.theme.ButtonState
3443
import com.getcode.ui.theme.CodeButton
@@ -61,7 +70,9 @@ internal fun TokenLeaderboard(
6170
start = CodeTheme.dimens.inset,
6271
end = CodeTheme.dimens.inset,
6372
top = CodeTheme.dimens.grid.x2 + padding.calculateTopPadding(),
64-
bottom = (CodeTheme.dimens.grid.x2 + padding.calculateBottomPadding() - reduceBottomPadding).coerceAtLeast(0.dp)
73+
bottom = (CodeTheme.dimens.grid.x2 + padding.calculateBottomPadding() - reduceBottomPadding).coerceAtLeast(
74+
0.dp
75+
)
6576
)
6677
) {
6778
when (tokens) {
@@ -145,15 +156,31 @@ internal fun TokenLeaderboard(
145156
} else {
146157
// leaderboard header
147158
item {
148-
Text(
159+
Row(
149160
modifier = Modifier.padding(
150161
top = CodeTheme.dimens.inset,
151162
bottom = CodeTheme.dimens.grid.x1,
152163
),
153-
text = stringResource(R.string.title_leaderboard),
154-
style = CodeTheme.typography.textLarge,
155-
color = CodeTheme.colors.textMain,
156-
)
164+
verticalAlignment = Alignment.CenterVertically,
165+
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
166+
) {
167+
Text(
168+
text = stringResource(R.string.title_leaderboard),
169+
style = CodeTheme.typography.textLarge,
170+
color = CodeTheme.colors.textMain,
171+
)
172+
173+
Icon(
174+
modifier = Modifier
175+
.size(CodeTheme.dimens.staticGrid.x4)
176+
.unboundedClickable {
177+
dispatch(TokenDiscoveryViewModel.Event.LearnAboutLeaderboard)
178+
},
179+
imageVector = Icons.Outlined.Info,
180+
tint = CodeTheme.colors.textSecondary,
181+
contentDescription = stringResource(R.string.content_description_leaderboard),
182+
)
183+
}
157184
}
158185

159186
itemsIndexed(

apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsViewModel.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,4 +141,5 @@ private fun ResolvedUserFlags.editableEntries(): List<EditableEntry<*>> = listOf
141141
EditableEntry(Field.NewCurrencyFeeAmount, newCurrencyFeeAmount),
142142
EditableEntry(Field.WithdrawalFeeAmount, withdrawalFeeAmount),
143143
EditableEntry(Field.PreferredUsdcOnRampLiquidityPool, usdcOnRampLiquidityPool),
144+
EditableEntry(Field.MinimumHolderAmountForLeaderboard, minimumHolderAmountForLeaderboard),
144145
)

apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/Field.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,25 @@ sealed class Field<Stored, Domain>(
169169
)
170170
),
171171
)
172+
173+
data object MinimumHolderAmountForLeaderboard : Field<Long, Fiat>(
174+
longPreferencesKey("override_min_holder_amount"),
175+
encode = { it.quarks },
176+
decode = { Fiat(quarks = it) },
177+
label = R.string.label_flag_minHolderAmount,
178+
hint = R.string.hint_flag_minHolderAmount,
179+
format = { it.formatted(rule = Fiat.FormattingRule.Truncated) },
180+
editFormat = { it.formatted(showPrefix = false, rule = Fiat.FormattingRule.Truncated) },
181+
editor = FieldEditor.TextInput(
182+
keyboard = KeyboardType.Decimal,
183+
parse = { it.toDoubleOrNull()?.let { d -> Fiat(fiat = d) } },
184+
),
185+
outputTransformation = OutputTransformation {
186+
if (length > 0) {
187+
insert(0, "$")
188+
}
189+
},
190+
)
172191
}
173192

174193
internal fun OnRampProvider.Defined.encode(): String = when (this) {

apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ data class ResolvedUserFlags(
3232
val withdrawalFeeAmount: ResolvedFlag<Fiat>,
3333
val usdcOnRampLiquidityPool: ResolvedFlag<UsdcLiquidtyPool>,
3434
val enablePhoneNumberSend: ResolvedFlag<Boolean>,
35+
val minimumHolderAmountForLeaderboard: ResolvedFlag<Fiat>,
3536
)
3637

3738
internal fun UserFlags.resolve(overrides: Overrides): ResolvedUserFlags = ResolvedUserFlags(
@@ -47,4 +48,5 @@ internal fun UserFlags.resolve(overrides: Overrides): ResolvedUserFlags = Resolv
4748
withdrawalFeeAmount = ResolvedFlag(withdrawalFeeAmount, overrides.withdrawalFeeAmount),
4849
usdcOnRampLiquidityPool = ResolvedFlag(preferredUsdcOnRampLiquidityPool, overrides.preferredUsdcOnRampLiquidityPool),
4950
enablePhoneNumberSend = ResolvedFlag(enablePhoneNumberSend, FieldOverride.None),
51+
minimumHolderAmountForLeaderboard = ResolvedFlag(minimumHolderValue, overrides.minimumHolderAmountForLeaderboard),
5052
)

apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import kotlin.time.Duration
2929

3030
@Singleton
3131
class UserFlagsCoordinator @Inject constructor(
32-
@ApplicationContext private val context: Context,
32+
@param:ApplicationContext private val context: Context,
3333
userManager: UserManager,
3434
dispatchers: DispatcherProvider,
3535
) {
@@ -42,6 +42,7 @@ class UserFlagsCoordinator @Inject constructor(
4242
val newCurrencyFeeAmount: FieldOverride<Fiat>,
4343
val withdrawalFeeAmount: FieldOverride<Fiat>,
4444
val preferredUsdcOnRampLiquidityPool: FieldOverride<UsdcLiquidtyPool>,
45+
val minimumHolderAmountForLeaderboard: FieldOverride<Fiat>,
4546
) {
4647
companion object {
4748
val None = Overrides(
@@ -53,6 +54,7 @@ class UserFlagsCoordinator @Inject constructor(
5354
newCurrencyFeeAmount = FieldOverride.None,
5455
withdrawalFeeAmount = FieldOverride.None,
5556
preferredUsdcOnRampLiquidityPool = FieldOverride.None,
57+
minimumHolderAmountForLeaderboard = FieldOverride.None,
5658
)
5759
}
5860
}
@@ -86,6 +88,7 @@ class UserFlagsCoordinator @Inject constructor(
8688
newCurrencyFeeAmount = prefs.readOverride(Field.NewCurrencyFeeAmount),
8789
withdrawalFeeAmount = prefs.readOverride(Field.WithdrawalFeeAmount),
8890
preferredUsdcOnRampLiquidityPool = prefs.readOverride(Field.PreferredUsdcOnRampLiquidityPool),
91+
minimumHolderAmountForLeaderboard = prefs.readOverride(Field.MinimumHolderAmountForLeaderboard),
8992
)
9093
}.stateIn(scope, SharingStarted.Eagerly, Overrides.None)
9194

apps/flipcash/shared/userflags/src/main/res/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,6 @@
1818
<string name="hint_flag_withdrawalFeeAmount">Enter amount</string>
1919
<string name="label_flag_preferredUsdcLiquidityPool">Preferred USDC On-Ramp Liquidity Pool</string>
2020
<string name="label_flag_enablePhoneNumberSend">Phone Number Send Enabled</string>
21+
<string name="label_flag_minHolderAmount">Minimum Holder Amount for Leaderboard</string>
22+
<string name="hint_flag_minHolderAmount">Enter amount</string>
2123
</resources>

0 commit comments

Comments
 (0)