diff --git a/apps/flipcash/features/userflags/build.gradle.kts b/apps/flipcash/features/userflags/build.gradle.kts index 75cd0bc03..d7b15af12 100644 --- a/apps/flipcash/features/userflags/build.gradle.kts +++ b/apps/flipcash/features/userflags/build.gradle.kts @@ -8,5 +8,6 @@ android { dependencies { implementation(project(":apps:flipcash:shared:userflags")) + implementation(project(":apps:flipcash:shared:region-selection:core")) implementation(project(":libs:messaging")) } diff --git a/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsEditor.kt b/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsEditor.kt index 64200873b..9cc77a8e4 100644 --- a/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsEditor.kt +++ b/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsEditor.kt @@ -24,6 +24,7 @@ import com.flipcash.app.core.data.Loadable import com.getcode.navigation.scrim.LocalScrimController import com.flipcash.app.userflags.internal.components.EditableRow import com.flipcash.app.userflags.internal.components.ReadOnlyRow +import com.flipcash.app.userflags.internal.components.ReadOnlyTextRow import com.getcode.theme.CodeTheme import com.getcode.ui.components.text.SectionHeader import com.getcode.ui.core.verticalScrollStateGradient @@ -82,6 +83,13 @@ private fun UserFlagsEditor( ) { entry -> ReadOnlyRow(entry) } + items( + items = state.readOnlyTextEntries, + key = { it.label }, + contentType = { "read_only_text_row" }, + ) { entry -> + ReadOnlyTextRow(entry) + } // Editable section item(contentType = "section_header") { SectionHeader("Overridable") } 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 005ffaa0b..e3735b5e4 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 @@ -9,8 +9,11 @@ import com.flipcash.app.userflags.ResolvedFlag import com.flipcash.app.userflags.ResolvedUserFlags import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.app.userflags.internal.components.FieldEditorSheet +import com.flipcash.app.currency.PreferredCurrencyController import com.flipcash.features.userflags.R import com.flipcash.libs.coroutines.DispatcherProvider +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.filterIsInstance @@ -21,6 +24,7 @@ import javax.inject.Inject @HiltViewModel internal class UserFlagsViewModel @Inject constructor( flagsCoordinator: UserFlagsCoordinator, + preferredCurrencyController: PreferredCurrencyController, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -29,6 +33,7 @@ internal class UserFlagsViewModel @Inject constructor( ) { internal data class State( val flags: Loadable = Loadable.Loading(), + val preferredCurrency: String? = null, ) { val readOnlyEntries: List get() = when (flags) { @@ -54,6 +59,30 @@ internal class UserFlagsViewModel @Inject constructor( else -> emptyList() } + val readOnlyTextEntries: List + get() = when (flags) { + is Loadable.Loaded -> { + val code = preferredCurrency?.let { CurrencyCode.tryValueOf(it) } ?: CurrencyCode.USD + val presets = flags.data.tipPresets.effectiveValue + .firstOrNull { it.region.equals(code.name, ignoreCase = true) } + fun format(amount: Double) = Fiat(fiat = amount, currencyCode = code).formatted() + listOf( + ReadOnlyTextEntry( + R.string.label_flag_tipPresets, + if (presets == null) "None" + else listOf( + "Min ${format(presets.minimum)}", + "Low ${format(presets.low)}", + "Med ${format(presets.medium)}", + "High ${format(presets.high)}", + ).joinToString(separator = "\n") + ), + ) + } + + else -> emptyList() + } + val editableEntries: List> get() = when (flags) { is Loadable.Loaded -> flags.data.editableEntries() @@ -63,6 +92,7 @@ internal class UserFlagsViewModel @Inject constructor( internal sealed interface Event { data class FlagsUpdated(val flags: Loadable) : Event + data class PreferredCurrencyUpdated(val currencyCode: String) : Event data class UpdateFlag( val field: Field, val value: Domain, @@ -81,6 +111,10 @@ internal class UserFlagsViewModel @Inject constructor( .onEach { dispatchEvent(Event.FlagsUpdated(Loadable.Loaded(it))) } .launchIn(viewModelScope) + preferredCurrencyController.observePreferredCurrency() + .onEach { dispatchEvent(Event.PreferredCurrencyUpdated(it)) } + .launchIn(viewModelScope) + eventFlow .filterIsInstance>() .onEach { event -> @@ -105,6 +139,10 @@ internal class UserFlagsViewModel @Inject constructor( state.copy(flags = event.flags) } + is Event.PreferredCurrencyUpdated -> { state -> + state.copy(preferredCurrency = event.currencyCode) + } + is Event.UpdateFlag<*, *> -> { state -> state } is Event.ResetFlag<*> -> { state -> state } is Event.Reset -> { state -> state } @@ -115,6 +153,8 @@ internal class UserFlagsViewModel @Inject constructor( internal data class ReadOnlyEntry(@get:StringRes val label: Int, val value: Boolean) +internal data class ReadOnlyTextEntry(@get:StringRes val label: Int, val value: String) + internal data class EditableEntry( val field: Field<*, Domain>, val resolved: ResolvedFlag, diff --git a/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/components/ReadOnlyTextRow.kt b/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/components/ReadOnlyTextRow.kt new file mode 100644 index 000000000..b75ab3605 --- /dev/null +++ b/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/components/ReadOnlyTextRow.kt @@ -0,0 +1,44 @@ +package com.flipcash.app.userflags.internal.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.flipcash.app.userflags.internal.ReadOnlyTextEntry +import com.getcode.theme.CodeTheme + +@Composable +internal fun ReadOnlyTextRow( + entry: ReadOnlyTextEntry, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Column( + modifier = Modifier.padding( + horizontal = CodeTheme.dimens.grid.x3, + vertical = CodeTheme.dimens.grid.x3, + ), + ) { + Text( + text = stringResource(entry.label), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + Text( + text = entry.value, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + + HorizontalDivider( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + color = CodeTheme.colors.divider, + thickness = 0.5.dp, + ) + } +} 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 ba4f1cf1c..e77604447 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 @@ -3,6 +3,7 @@ package com.flipcash.app.userflags import com.flipcash.app.userflags.UserFlagsCoordinator.Overrides import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.flipcash.services.internal.model.thirdparty.UsdcLiquidtyPool +import com.flipcash.services.models.TipPresets import com.flipcash.services.models.UserFlags import com.getcode.opencode.model.financial.Fiat import kotlin.time.Duration @@ -34,6 +35,7 @@ data class ResolvedUserFlags( val enablePhoneNumberSend: ResolvedFlag, val minimumHolderAmountForLeaderboard: ResolvedFlag, val requireCoinbaseEmailVerification: ResolvedFlag, + val tipPresets: ResolvedFlag>, ) internal fun UserFlags.resolve(overrides: Overrides): ResolvedUserFlags = ResolvedUserFlags( @@ -51,4 +53,5 @@ internal fun UserFlags.resolve(overrides: Overrides): ResolvedUserFlags = Resolv enablePhoneNumberSend = ResolvedFlag(enablePhoneNumberSend, FieldOverride.None), minimumHolderAmountForLeaderboard = ResolvedFlag(minimumHolderValue, overrides.minimumHolderAmountForLeaderboard), requireCoinbaseEmailVerification = ResolvedFlag(requireCoinbaseEmailVerification, overrides.requireCoinbaseEmailVerification), + tipPresets = ResolvedFlag(tipPresets, FieldOverride.None), ) \ 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 6d75ac9d8..196485507 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 @@ -11,6 +11,7 @@ import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.flipcash.services.internal.model.thirdparty.OnRampType import com.flipcash.services.internal.model.thirdparty.UsdcLiquidtyPool +import com.flipcash.services.models.TipPresets import com.flipcash.services.models.UserFlags import com.flipcash.services.user.UserManager import com.getcode.opencode.model.financial.CurrencyCode @@ -179,6 +180,7 @@ private data class CachedFlags( val enablePhoneNumberSend: Boolean, val minimumHolderValue: CachedFiat, val requireCoinbaseEmailVerification: Boolean, + val tipPresets: List = emptyList(), ) { fun toDomain(): UserFlags = UserFlags( isStaff = isStaff, @@ -197,6 +199,7 @@ private data class CachedFlags( enablePhoneNumberSend = enablePhoneNumberSend, minimumHolderValue = minimumHolderValue.toDomain(), requireCoinbaseEmailVerification = requireCoinbaseEmailVerification, + tipPresets = tipPresets.map { it.toDomain() }, ) companion object { @@ -219,6 +222,34 @@ private data class CachedFlags( enablePhoneNumberSend = flags.enablePhoneNumberSend, minimumHolderValue = CachedFiat.fromDomain(flags.minimumHolderValue), requireCoinbaseEmailVerification = flags.requireCoinbaseEmailVerification, + tipPresets = flags.tipPresets.map { CachedTipPresets.fromDomain(it) }, + ) + } +} + +@Serializable +private data class CachedTipPresets( + val region: String, + val minimum: Double, + val low: Double, + val medium: Double, + val high: Double, +) { + fun toDomain(): TipPresets = TipPresets( + region = region, + minimum = minimum, + low = low, + medium = medium, + high = high, + ) + + companion object { + fun fromDomain(presets: TipPresets): CachedTipPresets = CachedTipPresets( + region = presets.region, + minimum = presets.minimum, + low = presets.low, + medium = presets.medium, + high = presets.high, ) } } 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 68f5861de..d4bf666ce 100644 --- a/apps/flipcash/shared/userflags/src/main/res/values/strings.xml +++ b/apps/flipcash/shared/userflags/src/main/res/values/strings.xml @@ -21,4 +21,5 @@ Require Coinbase Email Verification Minimum Holder Amount for Leaderboard Enter amount + Tip Presets \ 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 f6e15f3e9..f584f5e31 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 @@ -166,4 +166,16 @@ message UserFlags { // Whether email verification is required for Coinbase purchase flows bool require_coinbase_email_verification = 14; + + // Tip presets for all currencies + repeated TipPresets tip_presets = 15; +} + +message TipPresets { + common.v1.Region region = 1 [(validate.rules).message.required = true]; + + double minimum = 2; + double low = 3; + double medium = 4; + double high = 5; } 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 1a5f0923d..152ecf9a8 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 @@ -2,6 +2,7 @@ package com.flipcash.services.internal.domain import com.codeinc.flipcash.gen.account.v1.FlipcashAccountService import com.flipcash.services.internal.domain.mapper.Mapper +import com.flipcash.services.models.TipPresets import com.flipcash.services.models.UserFlags import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.flipcash.services.internal.model.thirdparty.OnRampType @@ -29,10 +30,19 @@ internal class UserFlagsMapper @Inject constructor(): enablePhoneNumberSend = from.enablePhoneNumberSend, minimumHolderValue = Fiat(quarks = from.minimumHolderValue), requireCoinbaseEmailVerification = from.requireCoinbaseEmailVerification, + tipPresets = from.tipPresetsList.map { it.toDomain() }, ) } } +private fun FlipcashAccountService.TipPresets.toDomain(): TipPresets = TipPresets( + region = region.value, + minimum = minimum, + low = low, + medium = medium, + high = high, +) + private fun FlipcashAccountService.UserFlags.OnRampProvider.toDomain(): OnRampProvider { return when (this) { FlipcashAccountService.UserFlags.OnRampProvider.COINBASE_VIRTUAL -> OnRampProvider.Coinbase(OnRampType.Virtual) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/TipPresets.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/TipPresets.kt new file mode 100644 index 000000000..fd3ea113f --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/TipPresets.kt @@ -0,0 +1,15 @@ +package com.flipcash.services.models + +/** + * Suggested tip amounts for a given currency/region, sourced from server-side [UserFlags]. + * + * [region] is a lowercase 3-4 character region/currency code (matching `common.v1.Region.value`). + * The four amounts are expressed in the region's currency units. + */ +data class TipPresets( + val region: String, + val minimum: Double, + val low: Double, + val medium: Double, + val high: Double, +) 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 642c6a1de..ae0298497 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 @@ -20,6 +20,7 @@ data class UserFlags( val enablePhoneNumberSend: Boolean, val minimumHolderValue: Fiat, val requireCoinbaseEmailVerification: Boolean, + val tipPresets: List, ) { companion object { val Default = UserFlags( @@ -37,6 +38,7 @@ data class UserFlags( enablePhoneNumberSend = false, minimumHolderValue = Fiat.Zero, requireCoinbaseEmailVerification = false, + tipPresets = emptyList(), ) } } \ No newline at end of file diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/UserFlagsMapperTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/UserFlagsMapperTest.kt index 55ac8890d..12f85c3ed 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/UserFlagsMapperTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/UserFlagsMapperTest.kt @@ -148,4 +148,36 @@ class UserFlagsMapperTest { assertEquals(42, result.minimumVersion) } + + @Test + fun `maps tip presets`() { + val proto = FlipcashAccountService.UserFlags.newBuilder() + .addTipPresets( + FlipcashAccountService.TipPresets.newBuilder() + .setRegion(com.codeinc.flipcash.gen.common.v1.Common.Region.newBuilder().setValue("usd")) + .setMinimum(1.0) + .setLow(2.0) + .setMedium(5.0) + .setHigh(10.0) + ).build() + + val result = mapper.map(proto) + + assertEquals(1, result.tipPresets.size) + val presets = result.tipPresets.first() + assertEquals("usd", presets.region) + assertEquals(1.0, presets.minimum) + assertEquals(2.0, presets.low) + assertEquals(5.0, presets.medium) + assertEquals(10.0, presets.high) + } + + @Test + fun `maps empty tip presets by default`() { + val proto = userFlags { } + + val result = mapper.map(proto) + + assertTrue(result.tipPresets.isEmpty()) + } }