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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1495,6 +1495,28 @@
<string name="error_title_groupDescriptionSaveFailed">Couldn\'t Save the Description</string>
<string name="error_description_groupEditFailed">Try again</string>

<!-- Replacing a group's join or chat minimum balance from Edit Group's Balance Requirements
card. Entered on the shared amount keypad; confirmed before the call like the edits above. -->
<string name="title_groupJoinRequirement">Join Requirement</string>
<string name="title_groupChatRequirement">Chat Requirement</string>
<string name="description_groupJoinRequirement">People won\'t be able to join this group if their balance is less than this amount</string>
<string name="description_groupChatRequirement">People won\'t be able to send messages in this group if their balance is less than this amount</string>
<string name="prompt_title_changeGroupJoinRequirement">Change Join Requirement?</string>
<string name="prompt_description_changeGroupJoinRequirement">This will change who can join the group</string>
<string name="action_changeGroupJoinRequirement">Change Join Requirement</string>
<string name="prompt_title_changeGroupChatRequirement">Change Chat Requirement?</string>
<string name="prompt_description_changeGroupChatRequirement">This will change who can send messages in the group</string>
<string name="action_changeGroupChatRequirement">Change Chat Requirement</string>
<!-- Edit Group rules save: EditChat INVALID_RULES (also a Join at or above Chat), RULES_NOT_SATISFIED, and a Chat at or below Join. Match iOS. -->
<string name="error_title_groupRequirementInvalid">This Requirement Isn\'t Valid</string>
<string name="error_description_groupRequirementInvalid">Try a different minimum balance</string>
<string name="error_title_groupRequirementNotSatisfied">You Don\'t Meet This Requirement</string>
<string name="error_description_groupRequirementNotSatisfied">Lower the minimum balance or add funds</string>
<string name="error_title_groupChatMinimumTooLow">Chat Minimum Too Low</string>
<string name="error_description_groupChatMinimumTooLow">The chat minimum must be more than the join minimum of %1$s</string>
<string name="error_title_groupRequirementFailed">Couldn\'t Save the Requirement</string>
<string name="error_description_groupRequirementFailed">Try again</string>

<!-- Handing out the group's invite link, straight after it is created (node 10127:118315). -->
<string name="title_inviteToJoinGroup">Invite to Join Group</string>
<string name="action_sendInviteLink">Send Invite Link</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -297,6 +299,10 @@ private fun chatEntryProvider(
)
}

annotatedEntry<ChatStep.EditGroupBalanceRequirement> { step ->
FlowEditGroupBalanceRequirementScreen(step.role)
}

annotatedEntry<ChatStep.ReactionPicker> { step ->
FlowReactionPickerScreen(step.messageId)
}
Expand Down Expand Up @@ -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<ChatViewModel>())
Expand All @@ -755,6 +761,11 @@ private fun FlowEditGroupPictureScreen() {
EditGroupPictureScreen(flowSharedViewModel<ChatViewModel>())
}

@Composable
private fun FlowEditGroupBalanceRequirementScreen(role: GroupBalanceRole) {
EditGroupBalanceRequirementScreen(flowSharedViewModel<ChatViewModel>(), 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Event.GroupProfileOpened>()
.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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ChatStep, Parcelable>()
val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle()
val group = chatState.subject as? ChatSubject.Group

val viewModel = hiltViewModel<EditGroupBalanceRequirementViewModel>()

// 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<EditGroupBalanceRequirementViewModel.Event.OnRequirementAccepted>()
.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,
)
},
)
}
Loading
Loading