Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .claude/skills/fetch-protos/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand All @@ -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/<target>/src/**/models/`
2. Search for the corresponding mapper in `services/<target>/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<T>` 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<T>` 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.
Expand Down
4 changes: 4 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -689,4 +689,8 @@

<string name="action_depositOtherCurrencies">Deposit Other Flipcash Currencies</string>
<string name="action_withdrawOtherCurrencies">Withdraw Other Flipcash Currencies</string>

<string name="content_description_leaderboard">Learn more</string>
<string name="prompt_title_learnAboutLeaderboard">Leaderboard Ranking</string>
<string name="prompt_description_learnAboutLeaderboard">People must have a minimum balance of %1$s to be counted</string>
</resources>
1 change: 1 addition & 0 deletions apps/flipcash/features/discovery/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@ 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
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
Expand All @@ -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,
Expand All @@ -38,10 +43,13 @@ internal class TokenDiscoveryViewModel @Inject constructor(
val createEnabled: Boolean = false,
val category: DiscoverCategory? = null,
val tokens: Loadable<List<Token>> = 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
Expand All @@ -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)
Expand Down Expand Up @@ -100,7 +113,19 @@ internal class TokenDiscoveryViewModel @Inject constructor(
)
.launchIn(viewModelScope)


eventFlow
.filterIsInstance<Event.LearnAboutLeaderboard>()
.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 {
Expand All @@ -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)
}
Expand All @@ -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 }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,5 @@ private fun ResolvedUserFlags.editableEntries(): List<EditableEntry<*>> = listOf
EditableEntry(Field.NewCurrencyFeeAmount, newCurrencyFeeAmount),
EditableEntry(Field.WithdrawalFeeAmount, withdrawalFeeAmount),
EditableEntry(Field.PreferredUsdcOnRampLiquidityPool, usdcOnRampLiquidityPool),
EditableEntry(Field.MinimumHolderAmountForLeaderboard, minimumHolderAmountForLeaderboard),
)
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,25 @@ sealed class Field<Stored, Domain>(
)
),
)

data object MinimumHolderAmountForLeaderboard : Field<Long, Fiat>(
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ data class ResolvedUserFlags(
val withdrawalFeeAmount: ResolvedFlag<Fiat>,
val usdcOnRampLiquidityPool: ResolvedFlag<UsdcLiquidtyPool>,
val enablePhoneNumberSend: ResolvedFlag<Boolean>,
val minimumHolderAmountForLeaderboard: ResolvedFlag<Fiat>,
)

internal fun UserFlags.resolve(overrides: Overrides): ResolvedUserFlags = ResolvedUserFlags(
Expand All @@ -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),
)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
Expand All @@ -42,6 +42,7 @@ class UserFlagsCoordinator @Inject constructor(
val newCurrencyFeeAmount: FieldOverride<Fiat>,
val withdrawalFeeAmount: FieldOverride<Fiat>,
val preferredUsdcOnRampLiquidityPool: FieldOverride<UsdcLiquidtyPool>,
val minimumHolderAmountForLeaderboard: FieldOverride<Fiat>,
) {
companion object {
val None = Overrides(
Expand All @@ -53,6 +54,7 @@ class UserFlagsCoordinator @Inject constructor(
newCurrencyFeeAmount = FieldOverride.None,
withdrawalFeeAmount = FieldOverride.None,
preferredUsdcOnRampLiquidityPool = FieldOverride.None,
minimumHolderAmountForLeaderboard = FieldOverride.None,
)
}
}
Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@
<string name="hint_flag_withdrawalFeeAmount">Enter amount</string>
<string name="label_flag_preferredUsdcLiquidityPool">Preferred USDC On-Ramp Liquidity Pool</string>
<string name="label_flag_enablePhoneNumberSend">Phone Number Send Enabled</string>
<string name="label_flag_minHolderAmount">Minimum Holder Amount for Leaderboard</string>
<string name="hint_flag_minHolderAmount">Enter amount</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ data class UserFlags(
val withdrawalFeeAmount: Fiat,
val preferredUsdcOnRampLiquidityPool: UsdcLiquidtyPool,
val enablePhoneNumberSend: Boolean,
val minimumHolderValue: Fiat,
) {
companion object {
val Default = UserFlags(
Expand All @@ -33,6 +34,7 @@ data class UserFlags(
withdrawalFeeAmount = Fiat.Zero,
preferredUsdcOnRampLiquidityPool = UsdcLiquidtyPool.Unknown,
enablePhoneNumberSend = false,
minimumHolderValue = Fiat.Zero,
)
}
}
Loading