diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt
index 917cada750..491fe1bd80 100644
--- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt
@@ -2,6 +2,7 @@ package com.flipcash.app.core.chat
import android.os.Parcelable
import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.GroupBalanceRole
import com.getcode.solana.keys.Mint
import com.getcode.navigation.FullscreenSheet
import com.getcode.navigation.HalfSheet
@@ -192,7 +193,7 @@ sealed interface ChatStep : FlowStep, Parcelable {
* view model.
*
* Name, description, picture and cover each open their own editor and save on their own;
- * the balance requirements are shown read-only, since nothing on `EditChatRequest` writes them.
+ * the Balance Requirements card's rows open [EditGroupBalanceRequirement].
*/
@Parcelize
@Serializable
@@ -235,6 +236,15 @@ sealed interface ChatStep : FlowStep, Parcelable {
@Serializable
data object EditGroupDescription : ChatStep
+ /**
+ * One of the group's minimum balances, on the shared amount keypad — pushed from a row of
+ * [EditGroup]'s Balance Requirements card. [role] picks the listener (Join) or speaker (Chat)
+ * rule.
+ */
+ @Parcelize
+ @Serializable
+ data class EditGroupBalanceRequirement(val role: GroupBalanceRole) : ChatStep
+
/**
* The full emoji picker for a message's reaction, opened from the pill row's "+" or the
* quick strip's "+". A plain [Sheet] rather than [WrapContentSheet]: the picker's catalog
diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index be9f6e2903..92aee63651 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -1495,6 +1495,28 @@
Couldn\'t Save the Description
Try again
+
+ Join Requirement
+ Chat Requirement
+ People won\'t be able to join this group if their balance is less than this amount
+ People won\'t be able to send messages in this group if their balance is less than this amount
+ Change Join Requirement?
+ This will change who can join the group
+ Change Join Requirement
+ Change Chat Requirement?
+ This will change who can send messages in the group
+ Change Chat Requirement
+
+ This Requirement Isn\'t Valid
+ Try a different minimum balance
+ You Don\'t Meet This Requirement
+ Lower the minimum balance or add funds
+ Chat Minimum Too Low
+ The chat minimum must be more than the join minimum of %1$s
+ Couldn\'t Save the Requirement
+ Try again
+
Invite to Join Group
Send Invite Link
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt
index ad25d04d63..49f4c8169f 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt
@@ -75,6 +75,7 @@ import com.flipcash.app.messenger.internal.screens.profile.JoinRequestsScreen
import com.flipcash.app.messenger.internal.screens.profile.MembersScreen
import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupCoverScreen
import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupDescriptionScreen
+import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupBalanceRequirementScreen
import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupNameScreen
import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupPictureScreen
import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupScreen
@@ -83,6 +84,7 @@ import com.flipcash.services.models.chat.ChatId
import com.flipcash.shared.amountentry.AmountEntryScreen
import com.getcode.manager.BottomBarManager
import com.getcode.navigation.annotatedEntry
+import com.flipcash.services.models.chat.GroupBalanceRole
import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.navigation.flow.FlowHost
import com.getcode.navigation.flow.flowSharedViewModel
@@ -297,6 +299,10 @@ private fun chatEntryProvider(
)
}
+ annotatedEntry { step ->
+ FlowEditGroupBalanceRequirementScreen(step.role)
+ }
+
annotatedEntry { step ->
FlowReactionPickerScreen(step.messageId)
}
@@ -729,7 +735,7 @@ private fun FlowGroupProfileScreen() {
// The edit steps read the chat they are editing off the flow's shared ChatViewModel, the same way
// the profile above them does, and keep the edit itself in their own nav-entry-scoped view model.
-// The list itself holds no edit, but reads the group and its permissions the same way.
+// The list itself holds no edit, but reads the group, its permissions and its rules the same way.
@Composable
private fun FlowEditGroupScreen() {
EditGroupScreen(flowSharedViewModel())
@@ -755,6 +761,11 @@ private fun FlowEditGroupPictureScreen() {
EditGroupPictureScreen(flowSharedViewModel())
}
+@Composable
+private fun FlowEditGroupBalanceRequirementScreen(role: GroupBalanceRole) {
+ EditGroupBalanceRequirementScreen(flowSharedViewModel(), role)
+}
+
/**
* The full emoji picker for [messageId] — a tap on any emoji toggles it and dismisses. The search
* lives in the entry's own [EmojiPickerViewModel]; only the toggle goes to the chat.
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
index db91f4cd26..12186684e0 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
@@ -2350,6 +2350,15 @@ internal class ChatViewModel @AssistedInject constructor(
.onEach { dispatchEvent(Event.OnGroupResolved(it)) }
.launchIn(viewModelScope)
+ // RulesChanged reaches members only, so a stored group the viewer is outside of, as after
+ // a leave, can hold rules older than the ones a join is judged against. Fetched once each
+ // time the viewer is found outside, so the gate shows the current minimum before Join.
+ stateFlow.map { state -> (state.subject as? ChatSubject.Group)?.takeIf { it.isMember == false }?.chatId }
+ .distinctUntilChanged()
+ .filterNotNull()
+ .onEach { chatCoordinator.refreshRules(it) }
+ .launchIn(viewModelScope)
+
// The same metadata, minus the type filter: every chat has viewer state and every chat can
// be muted, so this one is not the group chrome's business. Observed rather than read with
// the chat because a mute made on another device arrives on the stream, and because leaving
@@ -2480,11 +2489,13 @@ internal class ChatViewModel @AssistedInject constructor(
}.launchIn(viewModelScope)
// A row that came from the feed can lack the cover the profile shows; GetChat has it. The
- // stored metadata updates and OnGroupResolved carries it into the subject.
+ // stored metadata updates and OnGroupResolved carries it into the subject. A viewer outside
+ // the group gets no RulesChanged, so opening the profile is also when its gate catches up.
eventFlow.filterIsInstance()
.onEach {
val chatId = stateFlow.value.chatId ?: return@onEach
val group = stateFlow.value.subject as? ChatSubject.Group ?: return@onEach
+ if (group.isMember == false) chatCoordinator.refreshRules(chatId)
if (group.coverPicture == null) chatCoordinator.refreshCover(chatId)
}
.launchIn(viewModelScope)
@@ -3023,6 +3034,9 @@ internal class ChatViewModel @AssistedInject constructor(
),
)
dispatchEvent(Event.JoinStateUpdated())
+ // The server judged the join against rules this device may not hold yet;
+ // storing them moves the gate to the minimum that actually refused it.
+ if (cause is JoinChatError.RulesNotSatisfied) chatCoordinator.refreshRules(chatId)
}
}
.launchIn(viewModelScope)
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementScreen.kt
new file mode 100644
index 0000000000..a4d1c58bde
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementScreen.kt
@@ -0,0 +1,120 @@
+package com.flipcash.app.messenger.internal.screens.profile.edit
+
+import android.os.Parcelable
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.flipcash.app.core.chat.ChatStep
+import com.flipcash.app.messenger.internal.ChatSubject
+import com.flipcash.app.messenger.internal.ChatViewModel
+import com.flipcash.features.messenger.R
+import com.flipcash.services.models.chat.GroupBalanceRole
+import com.flipcash.shared.amountentry.AmountEntryScreen
+import com.getcode.navigation.flow.rememberFlowNavigator
+import com.getcode.theme.CodeTheme
+import com.getcode.ui.components.AppBarWithTitle
+import com.getcode.ui.components.CircularIconButton
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+/**
+ * One of the group's minimum balances on the shared amount keypad, opened from a row of Edit
+ * Group's Balance Requirements card. [role] picks the rule: Join is the listener rule, Chat the
+ * group's own speaker rule, empty when it has none. A Chat minimum the group has can be removed
+ * from the bar, which returns the row to "None".
+ */
+@Composable
+internal fun EditGroupBalanceRequirementScreen(
+ chatViewModel: ChatViewModel,
+ role: GroupBalanceRole,
+) {
+ val flowNavigator = rememberFlowNavigator()
+ val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle()
+ val group = chatState.subject as? ChatSubject.Group
+
+ val viewModel = hiltViewModel()
+
+ // Keyed on the chat: the seed waits for the group to resolve and must not run again, or a rule
+ // arriving from the stream mid-edit would reset the baseline under the user.
+ LaunchedEffect(group?.chatId) {
+ val resolved = group ?: return@LaunchedEffect
+ viewModel.dispatchEvent(
+ EditGroupBalanceRequirementViewModel.Event.Initialize(
+ chatId = resolved.chatId,
+ role = role,
+ rules = resolved.rules,
+ )
+ )
+ }
+ val state by viewModel.stateFlow.collectAsStateWithLifecycle()
+
+ LaunchedEffect(viewModel) {
+ viewModel.eventFlow
+ .filterIsInstance()
+ .onEach { flowNavigator.back() }
+ .launchIn(this)
+ }
+
+ AmountEntryScreen(
+ controller = viewModel.amountDelegate,
+ onConfirm = {
+ viewModel.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.SaveClicked)
+ },
+ largeHeader = true,
+ appBar = {
+ AppBarWithTitle(
+ title = stringResource(
+ when (role) {
+ GroupBalanceRole.Join -> R.string.title_groupJoinRequirement
+ GroupBalanceRole.Chat -> R.string.title_groupChatRequirement
+ }
+ ),
+ titleAlignment = Alignment.CenterHorizontally,
+ onBackIconClicked = { flowNavigator.back() },
+ endContent = {
+ if (state.canRemove) {
+ CircularIconButton(
+ onClick = {
+ if (state.processingState.isIdle) {
+ viewModel.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.RemoveClicked)
+ }
+ },
+ ) { size ->
+ Icon(
+ modifier = Modifier.requiredSize(size),
+ painter = painterResource(com.getcode.util.resources.R.drawable.ic_delete),
+ contentDescription = stringResource(R.string.action_remove),
+ tint = Color.White,
+ )
+ }
+ }
+ },
+ )
+ },
+ headerCaption = {
+ Text(
+ text = stringResource(
+ when (role) {
+ GroupBalanceRole.Join -> R.string.description_groupJoinRequirement
+ GroupBalanceRole.Chat -> R.string.description_groupChatRequirement
+ }
+ ),
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textSecondary,
+ textAlign = TextAlign.Start,
+ )
+ },
+ )
+}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModel.kt
new file mode 100644
index 0000000000..8e4b47c761
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModel.kt
@@ -0,0 +1,445 @@
+package com.flipcash.app.messenger.internal.screens.profile.edit
+
+import androidx.annotation.VisibleForTesting
+import androidx.lifecycle.viewModelScope
+import com.flipcash.app.core.ui.ConfirmationStyle
+import com.flipcash.app.tokens.TokenCoordinator
+import com.flipcash.features.messenger.R
+import com.flipcash.libs.coroutines.DispatcherProvider
+import com.flipcash.services.models.EditChatError
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.ChatRuleRequirement
+import com.flipcash.services.models.chat.ChatRules
+import com.flipcash.services.models.chat.EditChatParameters
+import com.flipcash.services.models.chat.GroupBalanceRole
+import com.flipcash.shared.amountentry.AmountEntryDelegate
+import com.flipcash.shared.amountentry.AmountEntryLabel
+import com.flipcash.shared.amountentry.AmountEntryStyle
+import com.flipcash.shared.chat.ChatCoordinator
+import com.flipcash.shared.chat.listenerMinimum
+import com.flipcash.shared.chat.minimumBalanceIn
+import com.flipcash.shared.chat.restatedIn
+import com.flipcash.shared.chat.speakerMinimum
+import com.flipcash.shared.chat.unmetRequirements
+import com.flipcash.shared.chat.withMinimum
+import com.flipcash.shared.chat.withoutSpeakerMinimum
+import com.getcode.manager.BottomBarAction
+import com.getcode.manager.BottomBarManager
+import com.getcode.opencode.exchange.Exchange
+import com.getcode.opencode.internal.extensions.fractionDigits
+import com.getcode.opencode.model.financial.CurrencyCode
+import com.getcode.opencode.model.financial.Fiat
+import com.getcode.opencode.model.financial.Rate
+import com.getcode.opencode.model.financial.TokenWithBalance
+import com.getcode.util.resources.ResourceHelper
+import com.getcode.utils.ErrorUtils
+import com.getcode.view.BaseViewModel
+import com.getcode.view.LoadingSuccessState
+import com.getcode.view.SuccessHoldDuration
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+/**
+ * One of a group's minimum balances, behind a row of Edit Group's Balance Requirements card.
+ *
+ * Entry is in the preferred currency and stored in the listener rule's currency, which the
+ * speaker rule must share; a speaker rule also takes the listener's mints. Saving sends the whole
+ * rule set back through `EditChatRequest.rules` with only this minimum changed, so staff, creator
+ * and any other rule the group has go back as they came.
+ *
+ * What the server would refuse is stopped here first where it can be known: an amount below the
+ * currency's minimum transfer value or equal to the current one leaves Save disabled, and a Chat
+ * minimum not above Join, a Join minimum not below Chat, or new minimums the caller's own balance
+ * does not meet each raise an alert instead of a request.
+ */
+@HiltViewModel
+class EditGroupBalanceRequirementViewModel @Inject constructor(
+ dispatchers: DispatcherProvider,
+ private val exchange: Exchange,
+ private val chatCoordinator: ChatCoordinator,
+ tokenCoordinator: TokenCoordinator,
+ private val resources: ResourceHelper,
+) : BaseViewModel(
+ initialState = State(),
+ updateStateForEvent = updateStateForEvent,
+ defaultDispatcher = dispatchers.Default,
+) {
+ data class State(
+ val chatId: ChatId? = null,
+ val role: GroupBalanceRole = GroupBalanceRole.Join,
+ /** The group's rules as the editor opened them; the baseline every save is built from. */
+ val rules: ChatRules? = null,
+ /** The preferred rate the keypad enters in; null until it resolves. */
+ val rate: Rate? = null,
+ val rates: Map = emptyMap(),
+ /** The caller's balances, or null until they load; the self-check is skipped without them. */
+ val balances: List? = null,
+ val processingState: LoadingSuccessState = LoadingSuccessState(),
+ ) {
+ /** The rule being replaced, or null when the group has none for [role]. */
+ val current: ChatRuleRequirement.MinimumBalance?
+ get() = when (role) {
+ GroupBalanceRole.Join -> rules?.listenerMinimum
+ GroupBalanceRole.Chat -> rules?.speakerMinimum
+ }
+
+ /** Whether the Chat minimum can go back to "None": only one the group has can be removed. */
+ val canRemove: Boolean
+ get() = role == GroupBalanceRole.Chat && rules?.speakerMinimum != null
+ }
+
+ sealed interface Event {
+ /** The screen handing over the group, the rule being edited, and the group's rules. */
+ data class Initialize(
+ val chatId: ChatId,
+ val role: GroupBalanceRole,
+ val rules: ChatRules?,
+ ) : Event
+
+ data class RateChanged(val rate: Rate) : Event
+ data class RatesChanged(val rates: Map) : Event
+ data class BalancesChanged(val balances: List) : Event
+
+ /** Save, pressed. Checks the entry and proposes the change; [Submit] is dispatched by the prompt. */
+ data object SaveClicked : Event
+
+ /** Remove, pressed on the Chat minimum. [Submit] is dispatched by the prompt. */
+ data object RemoveClicked : Event
+
+ /** The confirmed rule set. */
+ data class Submit(val rules: ChatRules) : Event
+
+ data class UpdateProcessingState(
+ val loading: Boolean = false,
+ val success: Boolean = false,
+ ) : Event
+
+ /** The change was stored; the screen can leave. */
+ data object OnRequirementAccepted : Event
+ }
+
+ /** Hilt cannot inject a defaulted lambda, so tests swap this to observe what was reported. */
+ @VisibleForTesting
+ internal var reportError: (Throwable) -> Unit = ErrorUtils::handleError
+
+ /** Whether Save is live; the keypad's confirm gate. Exposed for tests. */
+ @VisibleForTesting
+ internal val canSave = MutableStateFlow(false)
+
+ val amountDelegate = AmountEntryDelegate(
+ exchange = exchange,
+ scope = viewModelScope,
+ style = MutableStateFlow(
+ AmountEntryStyle(
+ actionLabel = AmountEntryLabel.Plain(resources.getString(R.string.action_save)),
+ actionStyle = ConfirmationStyle.Button,
+ )
+ ),
+ loadingState = stateFlow.map { it.processingState }
+ .stateIn(viewModelScope, SharingStarted.Eagerly, LoadingSuccessState()),
+ confirmEnabled = canSave,
+ )
+
+ // What the seed typed, or null before it runs. `prefill` types on top of the entry, so it
+ // runs once; and the restated rule can land a unit off the stored one, so the seeded entry
+ // itself is what reads as unchanged.
+ private var seededEntry: Double? = null
+
+ init {
+ exchange.observePreferredRate()
+ .onEach { rate ->
+ exchange.getCurrency(rate.currency.name)?.let(amountDelegate::onCurrencyChanged)
+ dispatchEvent(Event.RateChanged(rate))
+ seedIfReady()
+ }.launchIn(viewModelScope)
+
+ exchange.observeRates()
+ .onEach { rates ->
+ dispatchEvent(Event.RatesChanged(rates))
+ seedIfReady()
+ }.launchIn(viewModelScope)
+
+ tokenCoordinator.tokenBalances
+ .onEach { dispatchEvent(Event.BalancesChanged(it)) }
+ .launchIn(viewModelScope)
+
+ eventFlow
+ .filterIsInstance()
+ .onEach { seedIfReady() }
+ .launchIn(viewModelScope)
+
+ combine(
+ amountDelegate.state.map { it.enteredAmount }.distinctUntilChanged(),
+ stateFlow,
+ ) { entered, state ->
+ state.processingState.isIdle && state.chatId != null && proposedAmount(entered, state) != null
+ }.onEach { canSave.value = it }.launchIn(viewModelScope)
+
+ eventFlow
+ .filterIsInstance()
+ .onEach {
+ val state = stateFlow.value
+ if (!state.processingState.isIdle) return@onEach
+ val rules = state.rules ?: return@onEach
+ val amount = proposedAmount(amountDelegate.state.value.enteredAmount, state) ?: return@onEach
+ when (val check = check(state, rules, amount)) {
+ is Check.Blocked -> BottomBarManager.showError(
+ title = resources.getString(check.title),
+ message = check.message,
+ )
+
+ is Check.Ready -> confirm(state.role, check.rules)
+ }
+ }.launchIn(viewModelScope)
+
+ eventFlow
+ .filterIsInstance()
+ .onEach {
+ val state = stateFlow.value
+ if (!state.processingState.isIdle || !state.canRemove) return@onEach
+ val rules = state.rules ?: return@onEach
+ confirm(GroupBalanceRole.Chat, rules.withoutSpeakerMinimum())
+ }.launchIn(viewModelScope)
+
+ eventFlow
+ .filterIsInstance()
+ .onEach { event ->
+ val state = stateFlow.value
+ val chatId = state.chatId ?: return@onEach
+ if (!state.processingState.isIdle) return@onEach
+
+ dispatchEvent(Event.UpdateProcessingState(loading = true))
+ // The coordinator stores the metadata the response carries, so every screen
+ // reading the chat moves to the new rules without waiting for RulesChanged.
+ chatCoordinator.editChat(chatId, EditChatParameters(rules = event.rules))
+ .onSuccess {
+ dispatchEvent(Event.UpdateProcessingState(success = true))
+ viewModelScope.launch {
+ delay(SuccessHoldDuration)
+ dispatchEvent(Event.OnRequirementAccepted)
+ dispatchEvent(Event.UpdateProcessingState())
+ }
+ }
+ .onFailure { cause ->
+ dispatchEvent(Event.UpdateProcessingState())
+ announceFailure(cause)
+ }
+ }.launchIn(viewModelScope)
+ }
+
+ /**
+ * The amount [entered] would store, in the listener rule's currency, or null when there is
+ * nothing to save: no listener rule or usable rate, an entry below the currency's minimum
+ * transfer value, or one that leaves the current rule as it is.
+ */
+ private fun proposedAmount(entered: Double, state: State): Fiat? {
+ val listener = state.rules?.listenerMinimum ?: return null
+ // `Rate.ignore` is in USD, so an unresolved rate would read the entry as dollars.
+ val rate = state.rate?.takeIf { it.isUsable() } ?: return null
+ if (seededEntry != null && entered == seededEntry) return null
+ val target = listener.amount.currencyCode
+ val amount = minimumBalanceIn(entered, rate, target, state.rates[target]) ?: return null
+ val current = state.current?.amount?.rounded(target.fractionDigits)
+ return amount.takeUnless { it.quarks == current?.quarks }
+ }
+
+ private sealed interface Check {
+ data class Blocked(val title: Int, val message: String) : Check
+ data class Ready(val rules: ChatRules) : Check
+ }
+
+ /**
+ * Whether the server would take [amount] for the state's role, checked where the answer is
+ * already known, and the rule set to send when it would.
+ */
+ private fun check(state: State, rules: ChatRules, amount: Fiat): Check {
+ val listener = rules.listenerMinimum ?: return Check.Blocked(
+ R.string.error_title_groupRequirementInvalid,
+ resources.getString(R.string.error_description_groupRequirementInvalid),
+ )
+ val step = amount.smallestUnit.quarks
+ val joinAmount = listener.amount.rounded(listener.amount.currencyCode.fractionDigits)
+ when (state.role) {
+ // A speaker minimum has to clear the listener's by at least the minimum transfer value.
+ GroupBalanceRole.Chat -> if (amount.quarks < joinAmount.quarks + step) {
+ return Check.Blocked(
+ R.string.error_title_groupChatMinimumTooLow,
+ resources.getString(
+ R.string.error_description_groupChatMinimumTooLow,
+ displayed(joinAmount, state).formatted(),
+ ),
+ )
+ }
+
+ GroupBalanceRole.Join -> {
+ val speaker = rules.speakerMinimum?.amount?.rounded(listener.amount.currencyCode.fractionDigits)
+ if (speaker != null && amount.quarks + step > speaker.quarks) {
+ return Check.Blocked(
+ R.string.error_title_groupRequirementInvalid,
+ resources.getString(R.string.error_description_groupRequirementInvalid),
+ )
+ }
+ }
+ }
+
+ val updated = rules.withMinimum(state.role, amount)
+ if (!callerMeets(updated, state)) {
+ return Check.Blocked(
+ R.string.error_title_groupRequirementNotSatisfied,
+ resources.getString(R.string.error_description_groupRequirementNotSatisfied),
+ )
+ }
+ return Check.Ready(updated)
+ }
+
+ /**
+ * Whether the caller's own balance meets every minimum in [rules], listener and speaker alike,
+ * which the server requires of whoever sets them. Only the minimums are weighed here: the
+ * rest of the set is unchanged and stays the server's to judge. With no balances yet, or no
+ * rate for the rule's currency, it reads as met and the server decides.
+ */
+ private fun callerMeets(rules: ChatRules, state: State): Boolean {
+ val balances = state.balances ?: return true
+ val minimums = (rules.listener + rules.speaker).filterIsInstance()
+ return unmetRequirements(
+ rules = ChatRules(listener = minimums, speaker = emptyList()),
+ balances = balances,
+ isStaff = false,
+ rates = state.rates,
+ ).isEmpty()
+ }
+
+ /** [amount] restated in the entry currency, or as it is when no rate can restate it. */
+ private fun displayed(amount: Fiat, state: State): Fiat {
+ val rate = state.rate ?: return amount
+ return amount.restatedIn(rate.currency, state.rates[amount.currencyCode], rate)
+ ?.rounded(rate.currency.fractionDigits)
+ ?: amount
+ }
+
+ private fun confirm(role: GroupBalanceRole, rules: ChatRules) {
+ val copy = promptCopy(role)
+ BottomBarManager.showAlert(
+ title = resources.getString(copy.title),
+ message = resources.getString(copy.message),
+ actions = listOf(
+ BottomBarAction(resources.getString(copy.action)) {
+ viewModelScope.launch {
+ // The bar dismisses on an animation; the spinner belongs to the
+ // screen behind it and would otherwise start underneath.
+ delay(150.milliseconds)
+ dispatchEvent(Event.Submit(rules))
+ }
+ }
+ ),
+ showCancel = true,
+ )
+ }
+
+ /**
+ * Types the current rule into the keypad, restated in the entry currency, once the rule and
+ * the rates to restate it are known. A group with no rule for the role leaves the keypad empty.
+ */
+ private fun seedIfReady() {
+ if (seededEntry != null) return
+ val state = stateFlow.value
+ if (state.chatId == null) return
+ val rate = state.rate?.takeIf { it.isUsable() } ?: return
+ val current = state.current
+ if (current == null) {
+ seededEntry = 0.0
+ return
+ }
+ val inEntryCurrency = current.amount
+ .restatedIn(rate.currency, state.rates[current.amount.currencyCode], rate)
+ ?.rounded(rate.currency.fractionDigits)
+ ?: return
+ // An untouched keypad reads "0" rather than empty; anything else was typed by the user.
+ if (amountDelegate.state.value.enteredAmount != 0.0) {
+ seededEntry = Double.NaN
+ return
+ }
+ if (inEntryCurrency.decimalValue > 0.0) amountDelegate.prefill(inEntryCurrency.decimalValue)
+ seededEntry = amountDelegate.state.value.enteredAmount
+ }
+
+ private fun announceFailure(cause: Throwable) {
+ when (cause) {
+ is EditChatError.RulesNotSatisfied -> BottomBarManager.showError(
+ title = resources.getString(R.string.error_title_groupRequirementNotSatisfied),
+ message = resources.getString(R.string.error_description_groupRequirementNotSatisfied),
+ )
+
+ is EditChatError.InvalidRules -> {
+ reportError(cause)
+ BottomBarManager.showError(
+ title = resources.getString(R.string.error_title_groupRequirementInvalid),
+ message = resources.getString(R.string.error_description_groupRequirementInvalid),
+ )
+ }
+
+ else -> {
+ reportError(cause)
+ BottomBarManager.showError(
+ title = resources.getString(R.string.error_title_groupRequirementFailed),
+ message = resources.getString(R.string.error_description_groupRequirementFailed),
+ )
+ }
+ }
+ }
+
+ private data class PromptCopy(val title: Int, val message: Int, val action: Int)
+
+ private fun promptCopy(role: GroupBalanceRole) = when (role) {
+ GroupBalanceRole.Join -> PromptCopy(
+ title = R.string.prompt_title_changeGroupJoinRequirement,
+ message = R.string.prompt_description_changeGroupJoinRequirement,
+ action = R.string.action_changeGroupJoinRequirement,
+ )
+
+ GroupBalanceRole.Chat -> PromptCopy(
+ title = R.string.prompt_title_changeGroupChatRequirement,
+ message = R.string.prompt_description_changeGroupChatRequirement,
+ action = R.string.action_changeGroupChatRequirement,
+ )
+ }
+
+ internal companion object {
+ val updateStateForEvent: (Event) -> (State.() -> State) = { event ->
+ when (event) {
+ is Event.Initialize -> { state ->
+ state.copy(chatId = event.chatId, role = event.role, rules = event.rules)
+ }
+
+ is Event.RateChanged -> { state -> state.copy(rate = event.rate) }
+ is Event.RatesChanged -> { state -> state.copy(rates = event.rates) }
+ is Event.BalancesChanged -> { state -> state.copy(balances = event.balances) }
+ is Event.UpdateProcessingState -> { state ->
+ state.copy(
+ processingState = LoadingSuccessState(
+ loading = event.loading,
+ success = event.success,
+ )
+ )
+ }
+
+ Event.SaveClicked -> { state -> state }
+ Event.RemoveClicked -> { state -> state }
+ is Event.Submit -> { state -> state }
+ Event.OnRequirementAccepted -> { state -> state }
+ }
+ }
+ }
+}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt
index c2b19fd8df..6692cd6307 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt
@@ -22,6 +22,8 @@ import com.flipcash.app.messenger.internal.screens.profile.GroupBalanceRequireme
import com.flipcash.app.messenger.internal.screens.profile.holdingLabel
import com.flipcash.features.messenger.R
import com.flipcash.services.models.chat.BlobAccessContext
+import com.flipcash.services.models.chat.GroupBalanceRole
+import com.flipcash.shared.chat.isBalanceEditable
import com.flipcash.shared.common.ui.profile.BalanceRequirementsCard
import com.flipcash.shared.common.ui.profile.CoverAndPhoto
import com.flipcash.shared.common.ui.profile.FieldCard
@@ -32,7 +34,12 @@ import com.getcode.ui.theme.CodeScaffold
/**
* The group's edit screen: its cover and picture, then a card for each text field, then the
- * balance requirements, which are shown and not editable.
+ * balance requirements.
+ *
+ * The requirements' Join and Chat rows open [EditGroupBalanceRequirementScreen] only where
+ * `EditChatRequest.rules` would be accepted: a public group created with a listener minimum
+ * ([isBalanceEditable]). Anywhere else the card is shown and not editable. Without a speaker rule,
+ * the Chat row shows the Join minimum, as the profile does.
*
* Every field opens its own editor and saves on its own, so nothing is held here. The screen is
* the door to those editors and leaves the moment the viewer stops being allowed through it: if
@@ -52,6 +59,7 @@ internal fun EditGroupScreen(chatViewModel: ChatViewModel) {
val requirements = remember(group?.rules) { GroupBalanceRequirements.from(group?.rules) }
val tokens = state.ruleTokens
+ val balanceEditable = group != null && isBalanceEditable(group.rules, group.isPrivate)
val grid = CodeTheme.dimens.staticGrid
CodeScaffold(
@@ -109,6 +117,12 @@ internal fun EditGroupScreen(chatViewModel: ChatViewModel) {
yourBalance = null,
compact = true,
footnote = stringResource(R.string.footer_groupBalanceRequirements),
+ onJoinClick = if (balanceEditable) {
+ { flowNavigator.navigateTo(ChatStep.EditGroupBalanceRequirement(GroupBalanceRole.Join)) }
+ } else null,
+ onChatClick = if (balanceEditable) {
+ { flowNavigator.navigateTo(ChatStep.EditGroupBalanceRequirement(GroupBalanceRole.Chat)) }
+ } else null,
)
}
}
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModelTest.kt
new file mode 100644
index 0000000000..458679a543
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModelTest.kt
@@ -0,0 +1,448 @@
+package com.flipcash.app.messenger.internal.screens.profile.edit
+
+import com.flipcash.app.tokens.TokenCoordinator
+import com.flipcash.services.models.EditChatError
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.ChatMetadata
+import com.flipcash.services.models.chat.ChatRuleRequirement
+import com.flipcash.services.models.chat.ChatRules
+import com.flipcash.services.models.chat.EditChatParameters
+import com.flipcash.services.models.chat.GroupBalanceRole
+import com.flipcash.shared.chat.ChatCoordinator
+import com.getcode.manager.BottomBarManager
+import com.getcode.opencode.exchange.Exchange
+import com.getcode.opencode.model.financial.Currency
+import com.getcode.opencode.model.financial.CurrencyCode
+import com.getcode.opencode.model.financial.Fiat
+import com.getcode.opencode.model.financial.HolderMetrics
+import com.getcode.opencode.model.financial.MintMetadata
+import com.getcode.opencode.model.financial.Rate
+import com.getcode.opencode.model.financial.TokenWithBalance
+import com.getcode.opencode.model.financial.VmMetadata
+import com.getcode.solana.keys.Mint
+import com.getcode.solana.keys.PublicKey
+import com.getcode.util.resources.ResourceHelper
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.TestCoroutineScheduler
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * The requirement editor enters in the preferred currency and saves the group's whole rule set
+ * through `EditChatRequest.rules`, with only the edited minimum changed and stored in the listener
+ * rule's currency. What the server would refuse is stopped before a request where it can be known.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class EditGroupBalanceRequirementViewModelTest {
+
+ private val chatCoordinator = mockk(relaxed = true)
+ private val resources = mockk(relaxed = true)
+ private val chatId = ChatId(ByteArray(32) { 9 }.toList())
+ private val mint = PublicKey(ByteArray(32) { 4 }.toList())
+
+ // Two euros to the dollar keeps the arithmetic readable: $10 seeds as 20, 30 saves as $15.
+ private val euroRate = Rate(fx = 2.0, currency = CurrencyCode.EUR)
+ private val usdRate = Rate(fx = 1.0, currency = CurrencyCode.USD)
+ private val yenRate = Rate(fx = 150.0, currency = CurrencyCode.JPY)
+
+ private val scheduler = TestCoroutineScheduler()
+ private val reported = mutableListOf()
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(UnconfinedTestDispatcher(scheduler))
+ BottomBarManager.clear()
+ coEvery { chatCoordinator.editChat(any(), any()) } returns Result.success(mockk(relaxed = true))
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ BottomBarManager.clear()
+ }
+
+ private fun model(
+ rules: ChatRules?,
+ role: GroupBalanceRole = GroupBalanceRole.Join,
+ rate: Rate = euroRate,
+ rates: Map = mapOf(CurrencyCode.EUR to euroRate, CurrencyCode.JPY to yenRate),
+ balances: List? = null,
+ ): EditGroupBalanceRequirementViewModel {
+ val exchange = mockk(relaxed = true) {
+ every { preferredRate } returns rate
+ every { observePreferredRate() } returns flowOf(rate)
+ every { observeRates() } returns flowOf(rates)
+ every { getCurrency(any()) } returns Currency(code = rate.currency.name, name = "", fractionUnits = 2)
+ every { rateFor(any()) } returns null
+ }
+ val tokenCoordinator = mockk(relaxed = true) {
+ every { tokenBalances } returns (balances?.let { flowOf(it) } ?: emptyFlow())
+ }
+ return EditGroupBalanceRequirementViewModel(
+ dispatchers = TestDispatchers(UnconfinedTestDispatcher(scheduler)),
+ exchange = exchange,
+ chatCoordinator = chatCoordinator,
+ tokenCoordinator = tokenCoordinator,
+ resources = resources,
+ ).apply {
+ reportError = { reported += it }
+ dispatchEvent(EditGroupBalanceRequirementViewModel.Event.Initialize(chatId, role, rules))
+ }
+ }
+
+ private fun minimum(amount: Double, currency: CurrencyCode = CurrencyCode.USD, mints: List = listOf(mint)) =
+ ChatRuleRequirement.MinimumBalance(Fiat(amount, currency), mints)
+
+ private fun rules(
+ join: ChatRuleRequirement.MinimumBalance = minimum(10.0),
+ chat: ChatRuleRequirement.MinimumBalance? = null,
+ listenerExtra: List = emptyList(),
+ speakerExtra: List = emptyList(),
+ ) = ChatRules(
+ listener = listOf(join) + listenerExtra,
+ speaker = listOfNotNull(chat) + speakerExtra,
+ )
+
+ private fun held(dollars: Double) = TokenWithBalance(
+ token = MintMetadata(
+ address = Mint(mint.bytes),
+ decimals = 6,
+ name = "T",
+ symbol = "T",
+ createdAt = null,
+ description = "",
+ imageUrl = "",
+ vmMetadata = VmMetadata(
+ vm = PublicKey.fromBase58("11111111111111111111111111111111"),
+ authority = PublicKey.fromBase58("11111111111111111111111111111111"),
+ lockDurationInDays = 21,
+ ),
+ launchpadMetadata = null,
+ billCustomizations = null,
+ socialLinks = emptyList(),
+ holderMetrics = HolderMetrics.None,
+ ),
+ balance = Fiat(dollars),
+ )
+
+ private fun EditGroupBalanceRequirementViewModel.type(vararg digits: Int) {
+ digits.forEach(amountDelegate::onNumber)
+ }
+
+ private fun EditGroupBalanceRequirementViewModel.clearEntry() {
+ repeat(10) { amountDelegate.onBackspace() }
+ }
+
+ private fun EditGroupBalanceRequirementViewModel.retype(vararg digits: Int) {
+ clearEntry()
+ type(*digits)
+ scheduler.advanceUntilIdle()
+ }
+
+ /** Save, then the prompt's confirm — the only path that writes. */
+ private fun EditGroupBalanceRequirementViewModel.saveAndConfirm() {
+ dispatchEvent(EditGroupBalanceRequirementViewModel.Event.SaveClicked)
+ scheduler.advanceUntilIdle()
+ BottomBarManager.messages.value.last().actions.first().onClick()
+ scheduler.advanceUntilIdle()
+ }
+
+ private fun sentRules(): ChatRules {
+ val sent = slot()
+ coVerify { chatCoordinator.editChat(chatId, capture(sent)) }
+ return checkNotNull(sent.captured.rules)
+ }
+
+ private fun assertNothingSent() {
+ coVerify(exactly = 0) { chatCoordinator.editChat(any(), any()) }
+ }
+
+ @Test
+ fun `seeds the current rule in the entry currency`() = runTest(scheduler) {
+ val model = model(rules())
+ advanceUntilIdle()
+
+ assertEquals(20.0, model.amountDelegate.state.value.enteredAmount, 0.0001)
+ // Unchanged from what is stored, so there is nothing to save yet.
+ assertFalse(model.canSave.value)
+ }
+
+ @Test
+ fun `an untouched rule in another currency still reads as unchanged`() = runTest(scheduler) {
+ // ¥1000 seeds as €13.33, which restates as ¥999.75 and rounds back to ¥1000 only by luck;
+ // the seeded entry itself is what has to read as unchanged.
+ val model = model(rules(join = minimum(1000.0, CurrencyCode.JPY)))
+ advanceUntilIdle()
+
+ assertEquals(13.33, model.amountDelegate.state.value.enteredAmount, 0.0001)
+ assertFalse(model.canSave.value)
+ }
+
+ @Test
+ fun `a group without a Chat minimum starts empty and has nothing to remove`() = runTest(scheduler) {
+ val model = model(rules(), role = GroupBalanceRole.Chat)
+ advanceUntilIdle()
+
+ assertEquals(0.0, model.amountDelegate.state.value.enteredAmount, 0.0)
+ assertFalse(model.canSave.value)
+ assertFalse(model.stateFlow.value.canRemove)
+ }
+
+ @Test
+ fun `save is enabled only for a positive amount that differs from the rule`() = runTest(scheduler) {
+ val model = model(rules())
+ advanceUntilIdle()
+
+ model.retype(0)
+ assertFalse("zero", model.canSave.value)
+
+ model.retype(2, 0)
+ assertFalse("same as the rule", model.canSave.value)
+
+ model.retype(3, 0)
+ assertTrue("changed", model.canSave.value)
+ }
+
+ @Test
+ fun `an amount below the minimum transfer value cannot be saved`() = runTest(scheduler) {
+ // €0.01 is half a cent, a quarter of a yen at ¥50 to the dollar: less than ¥1.
+ val model = model(
+ rules(join = minimum(1000.0, CurrencyCode.JPY)),
+ rates = mapOf(CurrencyCode.EUR to euroRate, CurrencyCode.JPY to Rate(fx = 50.0, currency = CurrencyCode.JPY)),
+ )
+ advanceUntilIdle()
+
+ model.clearEntry()
+ model.type(0)
+ model.amountDelegate.onDecimal()
+ model.type(0, 1)
+ advanceUntilIdle()
+
+ assertFalse(model.canSave.value)
+ }
+
+ @Test
+ fun `save stays disabled without a usable rate`() = runTest(scheduler) {
+ val model = model(rules(), rate = Rate.ignore)
+ advanceUntilIdle()
+
+ assertEquals("nothing to seed from", 0.0, model.amountDelegate.state.value.enteredAmount, 0.0)
+ model.type(5)
+ advanceUntilIdle()
+ assertFalse(model.canSave.value)
+ }
+
+ @Test
+ fun `a Join change keeps its currency, its mints and every other rule`() = runTest(scheduler) {
+ val speaker = minimum(40.0)
+ val model = model(
+ rules(
+ chat = speaker,
+ listenerExtra = listOf(ChatRuleRequirement.Staff),
+ speakerExtra = listOf(ChatRuleRequirement.Creator),
+ )
+ )
+ advanceUntilIdle()
+
+ model.retype(3, 0)
+ model.saveAndConfirm()
+
+ val sent = sentRules()
+ assertEquals(listOf(minimum(15.0), ChatRuleRequirement.Staff), sent.listener)
+ assertEquals(listOf(speaker, ChatRuleRequirement.Creator), sent.speaker)
+ }
+
+ @Test
+ fun `a new Chat minimum takes the listener's currency and mints`() = runTest(scheduler) {
+ val listenerMints = listOf(mint, PublicKey(ByteArray(32) { 5 }.toList()))
+ val model = model(
+ rules(join = minimum(1000.0, CurrencyCode.JPY, listenerMints), speakerExtra = listOf(ChatRuleRequirement.Staff)),
+ role = GroupBalanceRole.Chat,
+ )
+ advanceUntilIdle()
+
+ // €30 is $15, which is ¥2250.
+ model.type(3, 0)
+ advanceUntilIdle()
+ model.saveAndConfirm()
+
+ val sent = sentRules()
+ assertEquals(listOf(minimum(1000.0, CurrencyCode.JPY, listenerMints)), sent.listener)
+ assertEquals(listOf(ChatRuleRequirement.Staff, minimum(2250.0, CurrencyCode.JPY, listenerMints)), sent.speaker)
+ }
+
+ @Test
+ fun `a Chat minimum at or below Join is blocked before sending`() = runTest(scheduler) {
+ val model = model(rules(), role = GroupBalanceRole.Chat, rate = usdRate, rates = emptyMap())
+ advanceUntilIdle()
+
+ model.type(1, 0)
+ advanceUntilIdle()
+ model.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.SaveClicked)
+ advanceUntilIdle()
+
+ assertEquals(BottomBarManager.BottomBarMessageType.ERROR, BottomBarManager.messages.value.last().type)
+ assertNothingSent()
+ }
+
+ @Test
+ fun `a Chat minimum one cent above Join is accepted`() = runTest(scheduler) {
+ val model = model(rules(), role = GroupBalanceRole.Chat, rate = usdRate, rates = emptyMap())
+ advanceUntilIdle()
+
+ model.type(1, 0)
+ model.amountDelegate.onDecimal()
+ model.type(0, 1)
+ advanceUntilIdle()
+ model.saveAndConfirm()
+
+ assertEquals(listOf(minimum(10.01)), sentRules().speaker)
+ }
+
+ @Test
+ fun `a Join minimum at or above Chat is blocked before sending`() = runTest(scheduler) {
+ val model = model(rules(chat = minimum(20.0)), rate = usdRate, rates = emptyMap())
+ advanceUntilIdle()
+
+ model.retype(2, 0)
+ model.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.SaveClicked)
+ advanceUntilIdle()
+
+ assertEquals(BottomBarManager.BottomBarMessageType.ERROR, BottomBarManager.messages.value.last().type)
+ assertNothingSent()
+ }
+
+ @Test
+ fun `removing the Chat minimum sends the rules without it`() = runTest(scheduler) {
+ val model = model(rules(chat = minimum(20.0), speakerExtra = listOf(ChatRuleRequirement.Creator)), role = GroupBalanceRole.Chat)
+ advanceUntilIdle()
+ assertTrue(model.stateFlow.value.canRemove)
+
+ model.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.RemoveClicked)
+ advanceUntilIdle()
+ BottomBarManager.messages.value.last().actions.first().onClick()
+ advanceUntilIdle()
+
+ val sent = sentRules()
+ assertEquals(listOf(minimum(10.0)), sent.listener)
+ assertEquals(listOf(ChatRuleRequirement.Creator), sent.speaker)
+ }
+
+ @Test
+ fun `new minimums the caller does not meet are blocked before sending`() = runTest(scheduler) {
+ val model = model(rules(), balances = listOf(held(12.0)))
+ advanceUntilIdle()
+
+ // €30 is a $15 Join minimum; the caller holds $12.
+ model.retype(3, 0)
+ model.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.SaveClicked)
+ advanceUntilIdle()
+
+ assertEquals(BottomBarManager.BottomBarMessageType.ERROR, BottomBarManager.messages.value.last().type)
+ assertNothingSent()
+ }
+
+ @Test
+ fun `a Chat minimum the caller does not meet is blocked too`() = runTest(scheduler) {
+ val model = model(rules(), role = GroupBalanceRole.Chat, balances = listOf(held(12.0)))
+ advanceUntilIdle()
+
+ model.type(3, 0)
+ advanceUntilIdle()
+ model.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.SaveClicked)
+ advanceUntilIdle()
+
+ assertEquals(BottomBarManager.BottomBarMessageType.ERROR, BottomBarManager.messages.value.last().type)
+ assertNothingSent()
+ }
+
+ @Test
+ fun `a caller who meets the new minimums is asked before it sends`() = runTest(scheduler) {
+ val model = model(rules(), balances = listOf(held(20.0)))
+ advanceUntilIdle()
+
+ model.retype(3, 0)
+ model.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.SaveClicked)
+ advanceUntilIdle()
+
+ assertEquals(BottomBarManager.BottomBarMessageType.DESTRUCTIVE, BottomBarManager.messages.value.last().type)
+ assertNothingSent()
+ }
+
+ @Test
+ fun `a save leaves once the server takes it`() = runTest(scheduler) {
+ val model = model(rules())
+ advanceUntilIdle()
+
+ model.retype(3, 0)
+ model.saveAndConfirm()
+
+ coVerify(exactly = 1) { chatCoordinator.editChat(chatId, any()) }
+ assertTrue(model.stateFlow.value.processingState.isIdle)
+ assertTrue(reported.isEmpty())
+ }
+
+ @Test
+ fun `rules the server will not satisfy are announced but not reported`() = runTest(scheduler) {
+ coEvery { chatCoordinator.editChat(any(), any()) } returns Result.failure(EditChatError.RulesNotSatisfied())
+ val model = model(rules())
+ advanceUntilIdle()
+
+ model.retype(3, 0)
+ model.saveAndConfirm()
+
+ assertEquals(BottomBarManager.BottomBarMessageType.ERROR, BottomBarManager.messages.value.last().type)
+ assertTrue(reported.isEmpty())
+ assertTrue(model.stateFlow.value.processingState.isIdle)
+ }
+
+ @Test
+ fun `invalid rules are announced and reported`() = runTest(scheduler) {
+ val failure = EditChatError.InvalidRules()
+ coEvery { chatCoordinator.editChat(any(), any()) } returns Result.failure(failure)
+ val model = model(rules())
+ advanceUntilIdle()
+
+ model.retype(3, 0)
+ model.saveAndConfirm()
+
+ assertEquals(BottomBarManager.BottomBarMessageType.ERROR, BottomBarManager.messages.value.last().type)
+ assertEquals(listOf(failure), reported)
+ }
+
+ @Test
+ fun `any other failure is reported and shown as an error`() = runTest(scheduler) {
+ val failure = IllegalStateException("boom")
+ coEvery { chatCoordinator.editChat(any(), any()) } returns Result.failure(failure)
+ val model = model(rules())
+ advanceUntilIdle()
+
+ model.retype(3, 0)
+ model.saveAndConfirm()
+
+ assertEquals(BottomBarManager.BottomBarMessageType.ERROR, BottomBarManager.messages.value.last().type)
+ assertEquals(listOf(failure), reported)
+ }
+}
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupConfirmationTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupConfirmationTest.kt
index 39b62b1b10..b625ed4db8 100644
--- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupConfirmationTest.kt
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupConfirmationTest.kt
@@ -176,7 +176,7 @@ class EditGroupConfirmationTest {
}
}
-private class TestDispatchers(private val dispatcher: CoroutineDispatcher) : DispatcherProvider {
+internal class TestDispatchers(private val dispatcher: CoroutineDispatcher) : DispatcherProvider {
override val Default: CoroutineDispatcher get() = dispatcher
override val Main: CoroutineDispatcher get() = dispatcher
override val IO: CoroutineDispatcher get() = dispatcher
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt
index c873b03142..88b0ae0976 100644
--- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt
@@ -644,6 +644,16 @@ interface GroupOperations {
* or cover-less answer leaves whatever is stored alone.
*/
suspend fun refreshCover(chatId: ChatId)
+
+ /**
+ * Stores the rules `GetChat` holds for [chatId].
+ *
+ * A member follows rules changes through `RulesChanged`, but a viewer outside the group, as
+ * after a leave, receives no events for it, so the stored rules can be older than the ones a
+ * join is judged against. Writes only rules that came back, so a failed answer leaves the
+ * stored ones alone.
+ */
+ suspend fun refreshRules(chatId: ChatId)
}
interface ChatCoordinator :
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupRulesEdit.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupRulesEdit.kt
new file mode 100644
index 0000000000..0eba64918a
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupRulesEdit.kt
@@ -0,0 +1,101 @@
+package com.flipcash.shared.chat
+
+import com.flipcash.services.models.chat.ChatRuleRequirement
+import com.flipcash.services.models.chat.ChatRules
+import com.flipcash.services.models.chat.GroupBalanceRole
+import com.getcode.opencode.internal.extensions.fractionDigits
+import com.getcode.opencode.model.financial.CurrencyCode
+import com.getcode.opencode.model.financial.Fiat
+import com.getcode.opencode.model.financial.Rate
+
+/** The group's listener minimum balance, the rule behind the Join row. */
+val ChatRules.listenerMinimum: ChatRuleRequirement.MinimumBalance?
+ get() = listener.filterIsInstance().firstOrNull()
+
+/**
+ * The group's own speaker minimum balance, the rule behind the Chat row, or null when speaking
+ * needs nothing beyond joining.
+ */
+val ChatRules.speakerMinimum: ChatRuleRequirement.MinimumBalance?
+ get() = speaker.filterIsInstance().firstOrNull()
+
+/**
+ * Whether `EditChatRequest.rules` would accept a change to these rules: a public group created
+ * with a listener minimum. A private group is DENIED and any other group INVALID_RULES.
+ *
+ * A rule this build cannot decode also rules it out. The edit sends the whole set back, and
+ * [ChatRuleRequirement.UnsupportedSpeakerRule] has no wire form, so it could only be dropped,
+ * which would open the chat it was keeping closed.
+ */
+fun isBalanceEditable(rules: ChatRules?, isPrivate: Boolean): Boolean {
+ if (isPrivate || rules == null) return false
+ if (rules.listenerMinimum == null) return false
+ return ChatRuleRequirement.UnsupportedSpeakerRule !in rules.listener + rules.speaker
+}
+
+/**
+ * These rules with [role]'s minimum balance set to [amount], and everything else as it was.
+ *
+ * The listener rule keeps its mints. A speaker rule always takes the listener's mints, the
+ * server's condition for accepting one, and replaces the group's own speaker minimum in place, or
+ * is appended when there is none. [amount] is expected in the listener rule's currency.
+ */
+fun ChatRules.withMinimum(role: GroupBalanceRole, amount: Fiat): ChatRules {
+ val listenerRule = listenerMinimum ?: return this
+ return when (role) {
+ GroupBalanceRole.Join -> copy(
+ listener = listener.map { if (it === listenerRule) listenerRule.copy(amount = amount) else it },
+ )
+
+ GroupBalanceRole.Chat -> {
+ val replacement = ChatRuleRequirement.MinimumBalance(amount = amount, mints = listenerRule.mints)
+ val existing = speakerMinimum
+ copy(
+ speaker = if (existing == null) speaker + replacement
+ else speaker.map { if (it === existing) replacement else it },
+ )
+ }
+ }
+}
+
+/** These rules without the group's own speaker minimum: Chat reads "None" and speaking needs only joining. */
+fun ChatRules.withoutSpeakerMinimum(): ChatRules {
+ val existing = speakerMinimum ?: return this
+ return copy(speaker = speaker.filterNot { it === existing })
+}
+
+/**
+ * The minimum balance a keypad [entered] amount in [entryRate]'s currency stores, restated in
+ * [target] (the listener rule's currency) and rounded to its decimals, or null when it falls below
+ * [target]'s minimum transfer value (one unit of its last decimal place, $0.01 or ¥1) or no usable
+ * rate can restate it. [targetRate] is only read when [target] differs from both the entry
+ * currency and USD.
+ */
+fun minimumBalanceIn(
+ entered: Double,
+ entryRate: Rate,
+ target: CurrencyCode,
+ targetRate: Rate?,
+): Fiat? {
+ if (entered <= 0.0) return null
+ val inTarget = Fiat(entered, entryRate.currency).restatedIn(target, entryRate, targetRate) ?: return null
+ val rounded = inTarget.rounded(target.fractionDigits)
+ return rounded.takeIf { it >= rounded.smallestUnit }
+}
+
+/**
+ * This amount in [target], through USD, or null when a rate it needs is missing or unusable.
+ * [ownRate] is the rate for this amount's currency, [targetRate] the one for [target]; neither is
+ * read when its currency is [target] or USD.
+ */
+fun Fiat.restatedIn(target: CurrencyCode, ownRate: Rate?, targetRate: Rate?): Fiat? {
+ if (currencyCode == target) return this
+ val usd = if (currencyCode == CurrencyCode.USD) this else {
+ // `Rate.ignore` carries a near-zero fx, which would restate any amount as an enormous USD sum.
+ val rate = ownRate?.takeIf { it.isUsable() && it.fx > 0.0 && it.currency == currencyCode } ?: return null
+ convertingToUsdIfNeeded(rate)
+ }
+ if (target == CurrencyCode.USD) return usd
+ val rate = targetRate?.takeIf { it.isUsable() && it.fx > 0.0 && it.currency == target } ?: return null
+ return usd.convertingTo(rate)
+}
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt
index c3c5496294..cb1c1ce47a 100644
--- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt
@@ -215,6 +215,11 @@ class GroupFeedDelegate @Inject constructor(
metadataDataSource.updateCoverPicture(chatId, cover)
}
+ override suspend fun refreshRules(chatId: ChatId) {
+ val rules = chatController.getChat(chatId).getOrNull()?.rules ?: return
+ metadataDataSource.updateRules(chatId, rules)
+ }
+
/**
* Handles the list-level meaning of [changes], then hands them to [RosterStateHolder] for the
* roster itself.
diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt
index 08fa68b97e..279a762741 100644
--- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt
+++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt
@@ -4,11 +4,14 @@ import com.flipcash.app.persistence.sources.ChatMemberDataSource
import com.flipcash.app.persistence.sources.ChatMessageDataSource
import com.flipcash.app.persistence.sources.ChatMetadataDataSource
import com.flipcash.services.controllers.ChatController
+import com.flipcash.services.models.GetChatError
import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.chat.ChatFeedPage
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatMember
import com.flipcash.services.models.chat.ChatMetadata
+import com.flipcash.services.models.chat.ChatRuleRequirement
+import com.flipcash.services.models.chat.ChatRules
import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.models.chat.IdempotencyKey
import com.flipcash.services.models.chat.RosterChange
@@ -17,6 +20,7 @@ import com.flipcash.services.models.chat.StartChatParameters
import com.flipcash.services.user.UserManager
import com.flipcash.shared.chat.internal.RosterStateHolder
import com.flipcash.shared.chat.internal.delegates.GroupFeedDelegate
+import com.getcode.opencode.model.financial.Fiat
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@@ -254,6 +258,29 @@ class GroupFeedDelegateTest {
coVerify { metadataDataSource.setMembership(chatId, isMember = true) }
}
+ /** A viewer outside the group gets no `RulesChanged`, so its rules are fetched to stand in. */
+ @Test
+ fun `refreshing the rules stores the ones GetChat holds`() = runTest {
+ val rules = ChatRules(
+ listener = listOf(ChatRuleRequirement.MinimumBalance(Fiat(25.0), emptyList())),
+ speaker = emptyList(),
+ )
+ coEvery { controller.getChat(chatId) } returns Result.success(group().copy(rules = rules))
+
+ subject.refreshRules(chatId)
+
+ coVerify(exactly = 1) { metadataDataSource.updateRules(chatId, rules) }
+ }
+
+ @Test
+ fun `a failed rules refresh leaves the stored rules alone`() = runTest {
+ coEvery { controller.getChat(chatId) } returns Result.failure(GetChatError.Denied())
+
+ subject.refreshRules(chatId)
+
+ coVerify(exactly = 0) { metadataDataSource.updateRules(any(), any()) }
+ }
+
/** `metadata` is set only on your own join, and is the whole chat to put in the list. */
@Test
fun `your own join adds the chat to the list`() = runTest {
diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupRulesEditTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupRulesEditTest.kt
new file mode 100644
index 0000000000..b0e9c985ff
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupRulesEditTest.kt
@@ -0,0 +1,98 @@
+package com.flipcash.shared.chat
+
+import com.flipcash.services.models.chat.ChatRuleRequirement
+import com.flipcash.services.models.chat.ChatRules
+import com.flipcash.services.models.chat.GroupBalanceRole
+import com.getcode.opencode.model.financial.CurrencyCode
+import com.getcode.opencode.model.financial.Fiat
+import com.getcode.opencode.model.financial.Rate
+import com.getcode.solana.keys.PublicKey
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+class GroupRulesEditTest {
+
+ private val mint = PublicKey(ByteArray(32) { 4 }.toList())
+ private val listenerRule = ChatRuleRequirement.MinimumBalance(Fiat(1000.0, CurrencyCode.JPY), listOf(mint))
+
+ @Test
+ fun `only a public group with a listener minimum is editable`() {
+ val rules = ChatRules(listener = listOf(listenerRule), speaker = emptyList())
+
+ assertTrue(isBalanceEditable(rules, isPrivate = false))
+ assertFalse(isBalanceEditable(rules, isPrivate = true), "private")
+ assertFalse(isBalanceEditable(null, isPrivate = false), "no rules")
+ assertFalse(
+ isBalanceEditable(ChatRules(listener = listOf(ChatRuleRequirement.Staff), speaker = emptyList()), isPrivate = false),
+ "no listener minimum",
+ )
+ }
+
+ @Test
+ fun `a rule this build cannot send back rules out the edit`() {
+ val rules = ChatRules(listener = listOf(listenerRule), speaker = listOf(ChatRuleRequirement.UnsupportedSpeakerRule))
+
+ assertFalse(isBalanceEditable(rules, isPrivate = false))
+ }
+
+ @Test
+ fun `a speaker minimum replaces the group's own in place`() {
+ val rules = ChatRules(
+ listener = listOf(listenerRule),
+ speaker = listOf(
+ ChatRuleRequirement.Staff,
+ ChatRuleRequirement.MinimumBalance(Fiat(2000.0, CurrencyCode.JPY), emptyList()),
+ ChatRuleRequirement.Creator,
+ ),
+ )
+
+ val updated = rules.withMinimum(GroupBalanceRole.Chat, Fiat(3000.0, CurrencyCode.JPY))
+
+ assertEquals(
+ listOf(
+ ChatRuleRequirement.Staff,
+ ChatRuleRequirement.MinimumBalance(Fiat(3000.0, CurrencyCode.JPY), listOf(mint)),
+ ChatRuleRequirement.Creator,
+ ),
+ updated.speaker,
+ )
+ assertEquals(rules.listener, updated.listener)
+ }
+
+ @Test
+ fun `removing the speaker minimum leaves every other speaker rule`() {
+ val rules = ChatRules(
+ listener = listOf(listenerRule),
+ speaker = listOf(ChatRuleRequirement.MinimumBalance(Fiat(2000.0, CurrencyCode.JPY), listOf(mint)), ChatRuleRequirement.Creator),
+ )
+
+ assertEquals(listOf(ChatRuleRequirement.Creator), rules.withoutSpeakerMinimum().speaker)
+ val none = ChatRules(listener = listOf(listenerRule), speaker = emptyList())
+ assertSame(none, none.withoutSpeakerMinimum())
+ }
+
+ @Test
+ fun `an entry is restated in the listener's currency through USD`() {
+ val euro = Rate(fx = 2.0, currency = CurrencyCode.EUR)
+ val yen = Rate(fx = 150.0, currency = CurrencyCode.JPY)
+
+ assertEquals(Fiat(2250.0, CurrencyCode.JPY), minimumBalanceIn(30.0, euro, CurrencyCode.JPY, yen))
+ assertEquals(Fiat(15.0, CurrencyCode.USD), minimumBalanceIn(30.0, euro, CurrencyCode.USD, null))
+ assertNull(minimumBalanceIn(30.0, euro, CurrencyCode.JPY, null), "no rate for the listener's currency")
+ }
+
+ @Test
+ fun `an entry below the currency's minimum transfer value stores nothing`() {
+ val usd = Rate(fx = 1.0, currency = CurrencyCode.USD)
+ val yen = Rate(fx = 100.0, currency = CurrencyCode.JPY)
+
+ // $0.003 is ¥0.3, under ¥1.
+ assertNull(minimumBalanceIn(0.003, usd, CurrencyCode.JPY, yen))
+ assertNull(minimumBalanceIn(0.004, usd, CurrencyCode.USD, null))
+ assertEquals(Fiat(1.0, CurrencyCode.JPY), minimumBalanceIn(0.01, usd, CurrencyCode.JPY, yen))
+ }
+}
diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/BalanceRequirementsCard.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/BalanceRequirementsCard.kt
index 7efce6668d..f20fdcf262 100644
--- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/BalanceRequirementsCard.kt
+++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/BalanceRequirementsCard.kt
@@ -50,8 +50,11 @@ private val RowInset = 16.dp
* token's icon and name) and a chevron, and tapping it calls [onCommunityCurrencyClick]. The caller
* then passes bare amounts for [join] and [chat], since the token is already named above them.
*
- * [compact] is the read-only form on Edit Group: a 16 header, shorter rows with their labels in
- * the same 16 as the values, and the [footnote] beneath. The profile's own is the default.
+ * [compact] is the form on Edit Group: a 16 header, shorter rows with their labels in the same 16
+ * as the values, and the [footnote] beneath. The profile's own is the default.
+ *
+ * [onJoinClick] and [onChatClick] make their row tappable and give it a trailing chevron — Edit
+ * Group passes them to open the requirement's editor; the read-only profile passes neither.
*/
@Composable
fun BalanceRequirementsCard(
@@ -63,6 +66,8 @@ fun BalanceRequirementsCard(
onCommunityCurrencyClick: () -> Unit = {},
compact: Boolean = false,
footnote: String? = null,
+ onJoinClick: (() -> Unit)? = null,
+ onChatClick: (() -> Unit)? = null,
) {
Column(
modifier = modifier.fillMaxWidth(),
@@ -87,12 +92,14 @@ fun BalanceRequirementsCard(
label = stringResource(R.string.label_balanceRequirementJoin),
value = join ?: stringResource(R.string.label_balanceRequirementNone),
compact = compact,
+ onClick = onJoinClick,
)
RowSeparator()
RequirementRow(
label = stringResource(R.string.label_balanceRequirementChat),
value = chat ?: stringResource(R.string.label_balanceRequirementNone),
compact = compact,
+ onClick = onChatClick,
)
}
if (footnote != null) {
@@ -168,11 +175,12 @@ private fun CommunityCurrencyRow(onClick: () -> Unit, content: @Composable RowSc
}
@Composable
-private fun RequirementRow(label: String, value: String, compact: Boolean) {
+private fun RequirementRow(label: String, value: String, compact: Boolean, onClick: (() -> Unit)?) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(if (compact) CompactRowHeight else RowHeight)
+ .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = RowInset),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
@@ -182,11 +190,21 @@ private fun RequirementRow(label: String, value: String, compact: Boolean) {
style = if (compact) CodeTheme.typography.textMedium else CodeTheme.typography.textSmall,
color = CodeTheme.colors.textSecondary,
)
- Text(
- text = value,
- style = CodeTheme.typography.textMedium,
- color = CodeTheme.colors.textMain,
- )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = value,
+ style = CodeTheme.typography.textMedium,
+ color = CodeTheme.colors.textMain,
+ )
+ if (onClick != null) {
+ Icon(
+ modifier = Modifier.padding(start = 8.dp),
+ painter = painterResource(R.drawable.ic_chevron_right),
+ contentDescription = null,
+ tint = CodeTheme.colors.textSecondary,
+ )
+ }
+ }
}
}
@@ -203,5 +221,13 @@ private fun Preview_BalanceRequirementsCard() {
yourBalance = "$4.50 of Moony",
communityCurrency = { Text(text = "Moony", style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textMain) },
)
+ BalanceRequirementsCard(
+ join = "$10",
+ chat = null,
+ yourBalance = null,
+ compact = true,
+ onJoinClick = {},
+ onChatClick = {},
+ )
}
}
diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt
index 782befc9e6..292425aa36 100644
--- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt
+++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt
@@ -6,6 +6,7 @@ import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import app.cash.turbine.test
import com.flipcash.app.persistence.FlipcashDatabase
+import com.flipcash.app.persistence.converters.ChatRuleRequirementSerialized
import com.flipcash.app.persistence.converters.ChatRulesSerialized
import com.flipcash.app.persistence.entities.ChatMetadataEntity
import com.flipcash.services.models.chat.MediaItem
@@ -673,6 +674,32 @@ class ChatMetadataDaoTest {
}
}
+ /**
+ * `MetadataUpdate.RulesChanged` writes through `updateRules`, and every gate reads the rules
+ * off this observation, so each change has to re-emit the row: a speaker minimum added, then
+ * removed, with the listener rule and the creator rule kept as they were.
+ */
+ @Test
+ fun `observeById re-emits each rules change`() = runTest {
+ val join = ChatRuleRequirementSerialized.MinimumBalance(quarks = 10_000_000, currencyCode = "USD", mints = listOf("mint"))
+ val chat = ChatRuleRequirementSerialized.MinimumBalance(quarks = 20_000_000, currencyCode = "USD", mints = listOf("mint"))
+ val joinOnly = ChatRulesSerialized(listener = listOf(join), speaker = listOf(ChatRuleRequirementSerialized.Creator))
+ val withChat = joinOnly.copy(speaker = listOf(chat, ChatRuleRequirementSerialized.Creator))
+ dao.upsert(entity(chatType = "GROUP", rulesJson = joinOnly))
+
+ dao.observeById(CHAT_HEX).test {
+ assertEquals(joinOnly, awaitItem()?.rulesJson)
+
+ dao.updateRules(CHAT_HEX, withChat)
+ assertEquals(withChat, awaitItem()?.rulesJson)
+
+ dao.updateRules(CHAT_HEX, joinOnly)
+ assertEquals(joinOnly, awaitItem()?.rulesJson)
+
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
@Test
fun `observeById emits null for a chat this device has never stored`() = runTest {
dao.observeById(CHAT_HEX).test {
diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt
index ae23378044..80a8dd623c 100644
--- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt
+++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt
@@ -21,6 +21,7 @@ import com.flipcash.services.models.chat.RosterSummary
import com.flipcash.services.models.chat.ViewerState
import com.getcode.opencode.model.financial.CurrencyCode
import com.getcode.opencode.model.financial.Fiat
+import com.getcode.solana.keys.PublicKey
import com.flipcash.app.persistence.entities.EncryptionState
import com.flipcash.services.chat.MessageEncryption
import com.flipcash.services.chat.UndecryptableReason
@@ -391,6 +392,29 @@ class ChatEntityMapperTest {
)
}
+ /**
+ * The write `MetadataUpdate.RulesChanged` and a rules edit make: [ChatEntityMapper.rulesSerialized]
+ * into `rules_json`, read back with the row. A speaker minimum added and then removed has to
+ * come back each time as it was sent, with the rules around it untouched.
+ */
+ @Test
+ fun `a speaker minimum added and removed through a rules change survives the round trip`() {
+ val mint = PublicKey(ByteArray(32) { 4 }.toList())
+ val join = ChatRuleRequirement.MinimumBalance(Fiat(quarks = 1_000_000_000, currencyCode = CurrencyCode.JPY), listOf(mint))
+ val chat = ChatRuleRequirement.MinimumBalance(Fiat(quarks = 2_500_000_000, currencyCode = CurrencyCode.JPY), listOf(mint))
+ val withSpeaker = ChatRules(
+ listener = listOf(join, ChatRuleRequirement.Staff),
+ speaker = listOf(chat, ChatRuleRequirement.Creator),
+ )
+ val withoutSpeaker = withSpeaker.copy(speaker = listOf(ChatRuleRequirement.Creator))
+ val entity = mapper.toEntity(groupMetadata())
+
+ for (rules in listOf(withSpeaker, withoutSpeaker)) {
+ val stored = entity.copy(rulesJson = mapper.rulesSerialized(rules))
+ assertEquals(rules, mapper.toMetadata(stored, members = emptyList(), lastMessage = null).rules)
+ }
+ }
+
@Test
fun `group identity and roster survive the round trip`() {
val entity = mapper.toEntity(groupMetadata())
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatRules.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatRules.kt
index 10e3c410d2..7ffaf28287 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatRules.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatRules.kt
@@ -73,3 +73,12 @@ val ChatRuleRequirement.blocksReactions: Boolean
ChatRuleRequirement.Creator,
ChatRuleRequirement.UnsupportedSpeakerRule -> false
}
+
+/**
+ * Which of a group's minimum balances an edit replaces: the listener rule for [Join], the speaker
+ * rule for [Chat]. Mirrors iOS's `GroupBalanceRole`.
+ */
+enum class GroupBalanceRole {
+ Join,
+ Chat,
+}