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 458dc3a238..694031433e 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
@@ -94,9 +94,8 @@ sealed interface ChatStep : FlowStep, Parcelable {
* the flow is already open on the group, and every step here reads it off the conversation's
* view model.
*
- * The design node lists four rows; only [EditGroupPicture] and [EditGroupName] are built.
- * Membership card, description and social links have no field on `EditChatRequest` in
- * flipcash2 0.11.0, so there is nothing for them to write.
+ * 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.
*/
@Parcelize
@Serializable
@@ -112,6 +111,16 @@ sealed interface ChatStep : FlowStep, Parcelable {
@Serializable
data object EditGroupPicture : ChatStep
+ /** The group's banner — the same pick, upload and `EditChat` as the picture, for the cover. */
+ @Parcelize
+ @Serializable
+ data object EditGroupCover : ChatStep
+
+ /** The group's description, up to 160 characters; emptying it clears it. */
+ @Parcelize
+ @Serializable
+ data object EditGroupDescription : 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 f8acc4d035..e6b013469a 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -1138,39 +1138,53 @@
Something Went Wrong
We couldn\'t create this group. Please try again.
-
+
Edit Group
- Picture
- Name
-
- Set Picture
+ Group name
+ Add a name
+ Description
+ Add a description
+ People keep their balance. These amounts determine who can join and send messages.
+
+ Picture
+ Description
What\'s the group name?
-
-
+ Group Name
+ Add a description
+ %1$d characters
+ This description isn\'t allowed. Try different wording
+
+
Change Group Name?
This will change the group name for everyone in it
- Change Name
+ Change Group Name
Change Group Picture?
This will change the group picture for everyone in it
- Change Picture
-
- Can\'t Edit This Group
- You don\'t have permission to edit this group
+ Change Group Picture
+ Change Group Cover?
+ This will change the group cover for everyone in it
+ Change Group Cover
+ Change Group Description?
+ This will change the group description for everyone in it
+ Change Group Description
+
+ You Can\'t Edit This Group
+ Try again later
+ This Group No Longer Exists
+ It may have been deleted
+ This Name is Not Allowed
+ Try a different group name
+ This Photo Isn\'t Allowed
+ Try a different photo
- Something Went Wrong
- We couldn\'t save your changes. Please try again.
+ Couldn\'t Save This Name
+ Couldn\'t Save This Photo
+ Couldn\'t Save the Description
+ Try again
Invite to Join Group
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 4c53fa8b67..bb5a6a679e 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
@@ -36,6 +36,8 @@ import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent
import com.flipcash.app.messenger.internal.screens.profile.ChatProfileScreen
import com.flipcash.app.messenger.internal.screens.profile.ChatProfileViewModel
import com.flipcash.app.messenger.internal.screens.profile.GroupProfileScreen
+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.EditGroupNameScreen
import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupPictureScreen
import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupScreen
@@ -115,7 +117,7 @@ private fun chatEntryProvider(
FlowGroupProfileScreen()
}
annotatedEntry {
- EditGroupScreen()
+ FlowEditGroupScreen()
}
annotatedEntry {
FlowEditGroupNameScreen()
@@ -123,6 +125,12 @@ private fun chatEntryProvider(
annotatedEntry {
FlowEditGroupPictureScreen()
}
+ annotatedEntry {
+ FlowEditGroupCoverScreen()
+ }
+ annotatedEntry {
+ FlowEditGroupDescriptionScreen()
+ }
annotatedEntry { step ->
FlowReactionPickerScreen(step.messageId)
@@ -312,9 +320,24 @@ private fun FlowGroupProfileScreen() {
GroupProfileScreen(flowSharedViewModel())
}
-// Both edit steps read the chat they are editing off the flow's shared ChatViewModel, the same way
+// 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.
-// EditGroupScreen needs neither and is registered directly.
+// The list itself holds no edit, but reads the group and its permissions the same way.
+@Composable
+private fun FlowEditGroupScreen() {
+ EditGroupScreen(flowSharedViewModel())
+}
+
+@Composable
+private fun FlowEditGroupCoverScreen() {
+ EditGroupCoverScreen(flowSharedViewModel())
+}
+
+@Composable
+private fun FlowEditGroupDescriptionScreen() {
+ EditGroupDescriptionScreen(flowSharedViewModel())
+}
+
@Composable
private fun FlowEditGroupNameScreen() {
EditGroupNameScreen(flowSharedViewModel())
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupHoldingLabel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupHoldingLabel.kt
new file mode 100644
index 0000000000..1aaba48cd3
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupHoldingLabel.kt
@@ -0,0 +1,34 @@
+package com.flipcash.app.messenger.internal.screens.profile
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import com.flipcash.app.messenger.internal.RuleCurrency
+import com.flipcash.features.messenger.R
+import com.flipcash.services.models.chat.ChatRuleRequirement
+import com.getcode.opencode.model.financial.Fiat
+import com.getcode.solana.keys.Mint
+
+/**
+ * A rule's amount as the profile writes it: "$10", "$2.50", and "$10 of NYC" for a token other
+ * than USDF whose name is known. A rule naming no mint, or the reserve, states the amount alone,
+ * as does any rule when [namesToken] is false because the card already names the token.
+ */
+@Composable
+internal fun holdingLabel(
+ rule: ChatRuleRequirement.MinimumBalance,
+ tokens: Map, RuleCurrency>,
+ namesToken: Boolean = true,
+): String = holdingLabel(rule.amount, rule.mints.firstOrNull()?.takeIf { namesToken }?.let { Mint(it.bytes) }, tokens)
+
+@Composable
+internal fun holdingLabel(
+ amount: Fiat,
+ mint: Mint?,
+ tokens: Map, RuleCurrency>,
+): String {
+ val formatted = amount.formatted(Fiat.FormattingRule.Truncated)
+ val name = mint?.takeUnless { it == Mint.usdf }?.let { tokens[it.bytes] }?.name
+ ?: return formatted
+ return stringResource(R.string.label_chat_preview_cash_suffix, formatted, name)
+}
+
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt
index 86dc2278e8..54251d24dd 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt
@@ -394,30 +394,6 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) {
}
}
-/**
- * A rule's amount as the profile writes it: "$10", "$2.50", and "$10 of NYC" for a token other
- * than USDF whose name is known. A rule naming no mint, or the reserve, states the amount alone,
- * as does any rule when [namesToken] is false because the card already names the token.
- */
-@Composable
-private fun holdingLabel(
- rule: ChatRuleRequirement.MinimumBalance,
- tokens: Map, com.flipcash.app.messenger.internal.RuleCurrency>,
- namesToken: Boolean = true,
-): String = holdingLabel(rule.amount, rule.mints.firstOrNull()?.takeIf { namesToken }?.let { Mint(it.bytes) }, tokens)
-
-@Composable
-private fun holdingLabel(
- amount: Fiat,
- mint: Mint?,
- tokens: Map, com.flipcash.app.messenger.internal.RuleCurrency>,
-): String {
- val formatted = amount.formatted(Fiat.FormattingRule.Truncated)
- val name = mint?.takeUnless { it == Mint.usdf }?.let { tokens[it.bytes] }?.name
- ?: return formatted
- return stringResource(R.string.label_chat_preview_cash_suffix, formatted, name)
-}
-
/** The ⋯ button's menu: Encryption and Mute, then a divider and the red Report. */
@Composable
private fun GroupProfileMenu(
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitle.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitle.kt
index 3128415c47..6f4c7ede49 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitle.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitle.kt
@@ -22,5 +22,12 @@ internal object ChatTitle {
fun lengthOf(input: CharSequence): Int =
normalize(input).let { it.codePointCount(0, it.length) }
+ /** How many more code points the field takes, counted on the text as typed; never negative. */
+ fun remaining(input: CharSequence): Int =
+ (MAX_LENGTH - input.toString().let { it.codePointCount(0, it.length) }).coerceAtLeast(0)
+
+ /** The counter only appears once the end is in sight. */
+ const val COUNTER_THRESHOLD = 10
+
fun isValid(input: CharSequence): Boolean = lengthOf(input) in MIN_LENGTH..MAX_LENGTH
}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupCoverScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupCoverScreen.kt
new file mode 100644
index 0000000000..a2f60b9c50
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupCoverScreen.kt
@@ -0,0 +1,191 @@
+package com.flipcash.app.messenger.internal.screens.profile.edit
+
+import android.os.Parcelable
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.PickVisualMediaRequest
+import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia
+import androidx.compose.animation.Crossfade
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material3.Icon
+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.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import coil3.compose.AsyncImage
+import coil3.compose.LocalPlatformContext
+import coil3.request.ImageRequest
+import com.flipcash.app.core.chat.ChatStep
+import com.flipcash.shared.common.ui.profile.ProfileCover
+import com.flipcash.app.core.data.isLoaded
+import com.flipcash.app.core.data.isLoading
+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.BlobAccessContext
+import com.flipcash.shared.common.ui.ContactAvatar
+import com.getcode.navigation.flow.rememberFlowNavigator
+import com.getcode.theme.CodeTheme
+import com.getcode.theme.White50
+import com.getcode.ui.components.AppBarWithTitle
+import com.getcode.ui.theme.CodeButton
+import com.getcode.ui.theme.CodeCircularProgressIndicator
+import com.getcode.ui.theme.CodeScaffold
+import com.getcode.utils.TraceType
+import com.getcode.utils.trace
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+/**
+ * The group's cover. The banner is the picker: the stored cover shows in it until a pick replaces
+ * it, and Save stays off until one is made. Drawn at the height it has on Edit Group and clipped the
+ * same way, so what is picked is what the profile will show.
+ */
+@Composable
+internal fun EditGroupCoverScreen(chatViewModel: ChatViewModel) {
+ val flowNavigator = rememberFlowNavigator()
+ val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle()
+ val group = chatState.subject as? ChatSubject.Group
+
+ val viewModel = hiltViewModel()
+ val state by viewModel.stateFlow.collectAsStateWithLifecycle()
+
+ LaunchedEffect(group?.chatId) {
+ val resolved = group ?: return@LaunchedEffect
+ viewModel.dispatchEvent(
+ EditGroupCoverViewModel.Event.Initialize(
+ chatId = resolved.chatId,
+ cover = resolved.coverPicture,
+ )
+ )
+ }
+
+ LaunchedEffect(viewModel) {
+ viewModel.eventFlow
+ .filterIsInstance()
+ .onEach { flowNavigator.back() }
+ .launchIn(this)
+ }
+
+ val pickMedia = rememberLauncherForActivityResult(PickVisualMedia()) { uri ->
+ if (uri != null) {
+ trace(tag = TAG, message = "image selected @ $uri", type = TraceType.User)
+ viewModel.dispatchEvent(EditGroupCoverViewModel.Event.OnImageSelected(uri))
+ } else {
+ trace(tag = TAG, message = "No image selected", type = TraceType.User)
+ }
+ }
+
+ Column {
+ AppBarWithTitle(
+ title = stringResource(R.string.title_cover),
+ titleAlignment = Alignment.CenterHorizontally,
+ // The pick is dropped by leaving: the view model is scoped to this nav entry.
+ onBackIconClicked = { flowNavigator.back() },
+ )
+ CodeScaffold(
+ modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset),
+ topBar = {},
+ bottomBar = {
+ CodeButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(bottom = CodeTheme.dimens.grid.x3),
+ text = stringResource(R.string.action_save),
+ enabled = state.canSubmit,
+ isLoading = state.processingState.loading,
+ isSuccess = state.processingState.success,
+ onClick = {
+ viewModel.dispatchEvent(EditGroupCoverViewModel.Event.SaveClicked)
+ },
+ )
+ },
+ ) { padding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(top = CodeTheme.dimens.staticGrid.x4),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(CoverBannerHeight)
+ .clip(CodeTheme.shapes.medium)
+ .background(CodeTheme.colors.surfaceVariant)
+ .clickable {
+ pickMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly))
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ Crossfade(targetState = state.image) { pending ->
+ when {
+ pending.isLoaded() -> {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalPlatformContext.current)
+ .data(pending.data)
+ .build(),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+
+ pending.isLoading() && pending.dataOrNull != null -> {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CodeCircularProgressIndicator()
+ }
+ }
+
+ // No pick pending: the stored cover, so the screen opens on what the
+ // group has. Save stays disabled until a pick is made.
+ state.savedCover != null -> {
+ ProfileCover(
+ image = state.savedCover,
+ access = BlobAccessContext.Owned,
+ modifier = Modifier.fillMaxSize(),
+ height = Dp.Unspecified,
+ )
+ }
+
+ else -> {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = null,
+ tint = CodeTheme.colors.textSecondary,
+ modifier = Modifier.size(40.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private val CoverBannerHeight = 214.dp
+
+private const val TAG = "EditGroupCover"
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupCoverViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupCoverViewModel.kt
new file mode 100644
index 0000000000..3013414461
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupCoverViewModel.kt
@@ -0,0 +1,288 @@
+package com.flipcash.app.messenger.internal.screens.profile.edit
+
+import android.net.Uri
+import androidx.lifecycle.viewModelScope
+import com.flipcash.app.blob.BlobStorageCoordinator
+import com.flipcash.app.blob.ImageUploadPreparer
+import com.flipcash.app.core.data.Loadable
+import com.flipcash.app.core.moderation.moderationDescription
+import com.flipcash.features.messenger.R
+import com.flipcash.libs.coroutines.DispatcherProvider
+import com.flipcash.services.models.BlobRejectedException
+import com.flipcash.services.models.EditChatError
+import com.flipcash.services.models.blob.UploadPolicy
+import com.flipcash.services.models.chat.BlobId
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.MediaItem
+import com.flipcash.services.models.chat.RejectionReason
+import com.flipcash.shared.chat.ChatCoordinator
+import com.getcode.manager.BottomBarManager
+import com.getcode.util.resources.ContentReader
+import com.getcode.util.resources.ResourceHelper
+import com.getcode.view.BaseViewModel
+import com.getcode.view.LoadingSuccessState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.mapNotNull
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+
+/**
+ * The group-cover edit: the banner on Edit Group is the picker.
+ *
+ * The pick-prepare-upload path is `CreateGroupViewModel`'s, because group creation already
+ * collects a group picture and the two have to produce the same thing: an ORIGINAL rendition
+ * inside the server's current [UploadPolicy], uploaded through [BlobStorageCoordinator], whose
+ * `upload` returns only once the blob is READY. Nothing new uploads here.
+ *
+ * That READY guarantee is what makes the ordering correct: `EditChat` answers
+ * `PICTURE_BLOB_NOT_ACCEPTED` for a blob still PENDING or PROCESSING, and the only way to reach
+ * the edit call below is through a successful `upload`.
+ */
+@HiltViewModel
+class EditGroupCoverViewModel @Inject constructor(
+ dispatchers: DispatcherProvider,
+ private val chatCoordinator: ChatCoordinator,
+ private val blobStorage: BlobStorageCoordinator,
+ private val imagePreparer: ImageUploadPreparer,
+ private val contentReader: ContentReader,
+ private val resources: ResourceHelper,
+) : BaseViewModel(
+ initialState = State(),
+ updateStateForEvent = updateStateForEvent,
+ // The base hops to Dispatchers.Default to publish events. Taking it from the injected
+ // provider instead keeps every dispatcher this class touches on one seam.
+ defaultDispatcher = dispatchers.Default,
+) {
+ data class State(
+ val chatId: ChatId? = null,
+ /** The group's stored cover — what the banner shows until a pick replaces it. */
+ val savedCover: MediaItem? = null,
+ /** The pending pick, re-encoded and cached on disk. Null until one is made. */
+ val image: Loadable = Loadable.Loading(),
+ val mimeType: String? = null,
+ val uploadPolicy: UploadPolicy? = null,
+ val processingState: LoadingSuccessState = LoadingSuccessState(),
+ ) {
+ /** Only a fresh pick can be saved; the stored cover is not a change to itself. */
+ val isChanged: Boolean
+ get() = image.dataOrNull != null
+
+ val canSubmit: Boolean
+ get() = isChanged && processingState.isIdle && chatId != null
+ }
+
+ sealed interface Event {
+ data class Initialize(
+ val chatId: ChatId,
+ val cover: MediaItem?,
+ ) : Event
+
+ data class OnImageSelected(val image: Uri) : Event
+ data class OnImageCached(val image: Uri, val mimeType: String) : Event
+ data object DiscardPendingImage : Event
+ data class UploadPolicyLoaded(val policy: UploadPolicy) : Event
+ /**
+ * Save, pressed. The new cover is only proposed here: what uploads and sends it is
+ * [SubmitCover], dispatched by the confirmation's own action.
+ */
+ data object SaveClicked : Event
+
+ /** The confirmed change. Reachable only through the prompt [SaveClicked] raises. */
+ data object SubmitCover : Event
+ data class UpdateProcessingState(
+ val loading: Boolean = false,
+ val success: Boolean = false,
+ ) : Event
+
+ data object OnCoverAccepted : Event
+ }
+
+ init {
+ blobStorage.policy
+ .filterNotNull()
+ .onEach { dispatchEvent(Event.UploadPolicyLoaded(it)) }
+ .launchIn(viewModelScope)
+
+ eventFlow
+ .filterIsInstance()
+ .mapNotNull { event ->
+ // Re-encode inside the policy's dimension and byte caps, off the main thread —
+ // the same call group creation makes, so both produce blobs the server will take.
+ when (val outcome = imagePreparer.prepare(
+ uri = event.image,
+ policy = stateFlow.value.uploadPolicy,
+ fileNamePrefix = "group_cover",
+ )) {
+ is ImageUploadPreparer.Outcome.Prepared -> outcome.uri to outcome.mimeType
+ ImageUploadPreparer.Outcome.Unsupported -> {
+ rejectImage(
+ title = R.string.error_title_imageNotSupported,
+ message = R.string.error_description_imageNotSupported,
+ )
+ null
+ }
+
+ ImageUploadPreparer.Outcome.TooLarge -> {
+ rejectImage(
+ title = R.string.error_title_imageTooLarge,
+ message = R.string.error_description_imageTooLarge,
+ )
+ null
+ }
+
+ // The preparer has already said why in the log; there is nothing to add.
+ ImageUploadPreparer.Outcome.Unreadable -> null
+ }
+ }
+ .flowOn(dispatchers.IO)
+ .onEach { (cached, mime) -> dispatchEvent(Event.OnImageCached(cached, mime)) }
+ .launchIn(viewModelScope)
+
+ eventFlow
+ .filterIsInstance()
+ .onEach {
+ if (!stateFlow.value.canSubmit) return@onEach
+
+ showGroupChangeConfirmation(resources, GroupChangeField.Cover) {
+ viewModelScope.launch {
+ // The bar dismisses on an animation, and the upload's spinner
+ // belongs to the screen behind it — it would start underneath.
+ delay(150.milliseconds)
+ dispatchEvent(Event.SubmitCover)
+ }
+ }
+ }.launchIn(viewModelScope)
+
+ eventFlow
+ .filterIsInstance()
+ .onEach { submit() }
+ .launchIn(viewModelScope)
+ }
+
+ private suspend fun submit() {
+ val state = stateFlow.value
+ val chatId = state.chatId ?: return
+ val uri = state.image.dataOrNull ?: return
+ val mimeType = state.mimeType ?: return
+
+ dispatchEvent(Event.UpdateProcessingState(loading = true))
+
+ val blobId = uploadPicture(uri, mimeType)
+ if (blobId == null) {
+ dispatchEvent(Event.UpdateProcessingState())
+ return
+ }
+
+ // Cover only: every other field stays unset so the server leaves it alone.
+ chatCoordinator.editChat(
+ chatId = chatId,
+ parameters = coverOnly(blobId),
+ ).onSuccess {
+ dispatchEvent(Event.UpdateProcessingState(success = true))
+ delay(500.milliseconds)
+ dispatchEvent(Event.OnCoverAccepted)
+ dispatchEvent(Event.UpdateProcessingState())
+ }.onFailure { cause ->
+ dispatchEvent(Event.UpdateProcessingState())
+ announceEditFailure(cause)
+ }
+ }
+
+ /** Uploads the prepared picture and returns its READY blob, or null having reported why not. */
+ private suspend fun uploadPicture(uri: Uri, mimeType: String): BlobId? {
+ val bytes = contentReader.readBytes(uri)
+ if (bytes == null) {
+ BottomBarManager.showError(
+ title = resources.getString(R.string.error_title_moderationFailed),
+ message = resources.getString(R.string.error_description_moderationFailed),
+ )
+ return null
+ }
+
+ return blobStorage.upload(bytes = bytes, mimeType = mimeType)
+ .onFailure { cause ->
+ dispatchEvent(Event.DiscardPendingImage)
+ announcePictureRejection(cause)
+ }
+ .getOrNull()
+ }
+
+ private fun announcePictureRejection(cause: Throwable) {
+ val rejection = (cause as? BlobRejectedException)?.rejection
+ when (rejection?.reason) {
+ RejectionReason.MODERATION -> BottomBarManager.showAlert(
+ title = resources.getString(R.string.error_title_imageNotAllowed),
+ message = resources.getString(moderationDescription(rejection.flaggedCategory)),
+ )
+
+ else -> BottomBarManager.showError(
+ title = resources.getString(R.string.error_title_moderationFailed),
+ message = resources.getString(R.string.error_description_moderationFailed),
+ )
+ }
+ }
+
+ private fun announceEditFailure(cause: Throwable) {
+ // Storage took the blob and EditChat would not have it, so the pick is what has to
+ // change — it is dropped rather than left on screen inviting the same upload again.
+ if (cause is EditChatError.CoverPictureBlobNotAccepted) dispatchEvent(Event.DiscardPendingImage)
+ showGroupEditAlert(resources, groupEditAlert(cause, GroupChangeField.Cover))
+ }
+
+ private fun rejectImage(title: Int, message: Int) {
+ BottomBarManager.showAlert(
+ title = resources.getString(title),
+ message = resources.getString(message),
+ )
+ }
+
+ internal companion object {
+ val updateStateForEvent: (Event) -> (State.() -> State) = { event ->
+ when (event) {
+ is Event.Initialize -> { state ->
+ state.copy(
+ chatId = event.chatId,
+ savedCover = event.cover,
+ )
+ }
+
+ is Event.OnImageSelected -> { state ->
+ // Held as Loading with the source uri so the well can show the pick while it
+ // is being re-encoded, instead of going blank between tap and preview.
+ state.copy(image = Loadable.Loading(event.image))
+ }
+
+ is Event.OnImageCached -> { state ->
+ state.copy(
+ image = Loadable.Loaded(event.image),
+ mimeType = event.mimeType,
+ )
+ }
+
+ Event.DiscardPendingImage -> { state ->
+ state.copy(image = Loadable.Loading(), mimeType = null)
+ }
+
+ is Event.UploadPolicyLoaded -> { state -> state.copy(uploadPolicy = event.policy) }
+ Event.SaveClicked -> { state -> state }
+ Event.SubmitCover -> { state -> state }
+ Event.OnCoverAccepted -> { state -> state }
+ is Event.UpdateProcessingState -> { state ->
+ state.copy(
+ processingState = state.processingState.copy(
+ loading = event.loading,
+ success = event.success,
+ )
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupDescriptionScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupDescriptionScreen.kt
new file mode 100644
index 0000000000..dbbe26dd28
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupDescriptionScreen.kt
@@ -0,0 +1,150 @@
+package com.flipcash.app.messenger.internal.screens.profile.edit
+
+import android.os.Parcelable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardCapitalization
+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.core.ui.DisplayTextInput
+import com.flipcash.app.core.ui.transitions.RequestFocusWhenSettled
+import com.flipcash.app.messenger.internal.ChatSubject
+import com.flipcash.app.messenger.internal.ChatViewModel
+import com.flipcash.features.messenger.R
+import com.getcode.navigation.flow.rememberFlowNavigator
+import com.getcode.theme.CodeTheme
+import com.getcode.ui.components.AppBarWithTitle
+import com.getcode.ui.theme.CodeButton
+import com.getcode.ui.theme.CodeScaffold
+import com.getcode.ui.utils.rememberKeyboardController
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+/**
+ * The group's description: a multiline field of three to eight lines, the characters left under it
+ * (red once it is over), and a moderated description's refusal under that.
+ */
+@Composable
+internal fun EditGroupDescriptionScreen(chatViewModel: ChatViewModel) {
+ val flowNavigator = rememberFlowNavigator()
+ val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle()
+ val group = chatState.subject as? ChatSubject.Group
+
+ val viewModel = hiltViewModel()
+ val state by viewModel.stateFlow.collectAsStateWithLifecycle()
+ val keyboard = rememberKeyboardController()
+
+ // Keyed on the chat so the seed waits for the group and never runs over an edit in progress.
+ LaunchedEffect(group?.chatId) {
+ val resolved = group ?: return@LaunchedEffect
+ viewModel.dispatchEvent(
+ EditGroupDescriptionViewModel.Event.Initialize(
+ chatId = resolved.chatId,
+ description = resolved.description.orEmpty(),
+ )
+ )
+ }
+
+ LaunchedEffect(viewModel) {
+ viewModel.eventFlow
+ .filterIsInstance()
+ .onEach { flowNavigator.back() }
+ .launchIn(this)
+ }
+
+ Column {
+ AppBarWithTitle(
+ title = stringResource(R.string.title_setGroupDescription),
+ titleAlignment = Alignment.CenterHorizontally,
+ onBackIconClicked = { keyboard.hideIfVisible { flowNavigator.back() } },
+ )
+ val draft = state.draft
+ CodeScaffold(
+ modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset),
+ bottomBar = {
+ CodeButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(
+ top = CodeTheme.dimens.grid.x6,
+ bottom = CodeTheme.dimens.staticGrid.x4,
+ ).imePadding(),
+ text = stringResource(R.string.action_save),
+ enabled = state.canSubmit,
+ isLoading = state.processingState.loading,
+ isSuccess = state.processingState.success,
+ onClick = {
+ keyboard.hideIfVisible {
+ viewModel.dispatchEvent(EditGroupDescriptionViewModel.Event.SaveClicked)
+ }
+ },
+ )
+ },
+ ) { padding ->
+ val focusRequester = remember { FocusRequester() }
+ Column(
+ modifier = Modifier
+ .padding(padding)
+ .padding(top = CodeTheme.dimens.staticGrid.x4),
+ verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x2),
+ ) {
+ DisplayTextInput(
+ state = state.fieldState,
+ placeholder = stringResource(R.string.hint_groupDescription),
+ style = CodeTheme.typography.textLarge.copy(color = CodeTheme.colors.textMain),
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ minLines = 3,
+ maxLines = 8,
+ keyboardOptions = KeyboardOptions(
+ capitalization = KeyboardCapitalization.Sentences,
+ imeAction = ImeAction.Default,
+ ),
+ )
+
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = draft.remaining.toString(),
+ style = CodeTheme.typography.textSmall,
+ color = if (draft.remaining < 0) {
+ CodeTheme.colors.errorText
+ } else {
+ CodeTheme.colors.textSecondary
+ },
+ textAlign = TextAlign.End,
+ )
+
+ if (draft.moderated) {
+ Text(
+ text = stringResource(R.string.error_groupDescriptionNotAllowed),
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.errorText,
+ )
+ }
+ }
+
+ RequestFocusWhenSettled(focusRequester)
+ }
+ }
+}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupDescriptionViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupDescriptionViewModel.kt
new file mode 100644
index 0000000000..3f23ea7364
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupDescriptionViewModel.kt
@@ -0,0 +1,150 @@
+package com.flipcash.app.messenger.internal.screens.profile.edit
+
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
+import androidx.lifecycle.viewModelScope
+import com.flipcash.libs.coroutines.DispatcherProvider
+import com.flipcash.services.models.EditChatError
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.shared.chat.ChatCoordinator
+import com.getcode.util.resources.ResourceHelper
+import com.getcode.view.BaseViewModel
+import com.getcode.view.LoadingSuccessState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+
+/**
+ * The group's description. Seeded once by the screen from the conversation, and saved through the
+ * same ask-then-send gate as the other group edits.
+ *
+ * A moderated description is the one failure shown beside the field, since it is the text that
+ * has to change and the field is where that happens. Anything else is a dialog and leaves the
+ * draft as it was.
+ */
+@HiltViewModel
+class EditGroupDescriptionViewModel @Inject constructor(
+ dispatchers: DispatcherProvider,
+ private val chatCoordinator: ChatCoordinator,
+ private val resources: ResourceHelper,
+) : BaseViewModel(
+ initialState = State(),
+ updateStateForEvent = updateStateForEvent,
+ defaultDispatcher = dispatchers.Default,
+) {
+ data class State(
+ val fieldState: TextFieldState = TextFieldState(),
+ val chatId: ChatId? = null,
+ val original: String = "",
+ /** The text the server refused. The refusal describes it and goes away with the next edit. */
+ val moderatedText: String? = null,
+ val processingState: LoadingSuccessState = LoadingSuccessState(),
+ ) {
+ val draft: GroupDescriptionDraft
+ get() = fieldState.text.toString().let {
+ GroupDescriptionDraft(original = original, text = it, moderated = it == moderatedText)
+ }
+
+ val canSubmit: Boolean
+ get() = draft.canSave && processingState.isIdle && chatId != null
+ }
+
+ sealed interface Event {
+ data class Initialize(val chatId: ChatId, val description: String) : Event
+
+ /** Save, pressed: proposes the change; [SubmitDescription] is what the prompt's action sends. */
+ data object SaveClicked : Event
+ data object SubmitDescription : Event
+ data class OnModerated(val text: String) : Event
+ data class UpdateProcessingState(
+ val loading: Boolean = false,
+ val success: Boolean = false,
+ ) : Event
+
+ data object OnDescriptionAccepted : Event
+ }
+
+ init {
+ eventFlow
+ .filterIsInstance()
+ .onEach { event ->
+ stateFlow.value.fieldState.setTextAndPlaceCursorAtEnd(event.description)
+ }.launchIn(viewModelScope)
+
+ eventFlow
+ .filterIsInstance()
+ .onEach {
+ if (!stateFlow.value.canSubmit) return@onEach
+
+ showGroupChangeConfirmation(resources, GroupChangeField.Description) {
+ viewModelScope.launch {
+ // The bar dismisses on an animation, and the save's spinner belongs to
+ // the screen behind it — it would start underneath.
+ delay(150.milliseconds)
+ dispatchEvent(Event.SubmitDescription)
+ }
+ }
+ }.launchIn(viewModelScope)
+
+ eventFlow
+ .filterIsInstance()
+ .onEach { submit() }
+ .launchIn(viewModelScope)
+ }
+
+ private suspend fun submit() {
+ val state = stateFlow.value
+ val chatId = state.chatId ?: return
+ if (!state.canSubmit) return
+
+ dispatchEvent(Event.UpdateProcessingState(loading = true))
+
+ chatCoordinator.editChat(
+ chatId = chatId,
+ parameters = descriptionOnly(state.draft.edit),
+ ).onSuccess {
+ dispatchEvent(Event.UpdateProcessingState(success = true))
+ delay(500.milliseconds)
+ dispatchEvent(Event.OnDescriptionAccepted)
+ dispatchEvent(Event.UpdateProcessingState())
+ }.onFailure { cause ->
+ dispatchEvent(Event.UpdateProcessingState())
+ if (cause is EditChatError.DescriptionModerated) {
+ dispatchEvent(Event.OnModerated(state.draft.text))
+ } else {
+ showGroupEditAlert(
+ resources,
+ groupEditAlert(cause, GroupChangeField.Description),
+ )
+ }
+ }
+ }
+
+ internal companion object {
+ val updateStateForEvent: (Event) -> (State.() -> State) = { event ->
+ when (event) {
+ is Event.Initialize -> { state ->
+ state.copy(chatId = event.chatId, original = event.description, moderatedText = null)
+ }
+
+ is Event.OnModerated -> { state -> state.copy(moderatedText = event.text) }
+ Event.SaveClicked -> { state -> state }
+ Event.SubmitDescription -> { state -> state }
+ Event.OnDescriptionAccepted -> { state -> state }
+ is Event.UpdateProcessingState -> { state ->
+ state.copy(
+ processingState = state.processingState.copy(
+ loading = event.loading,
+ success = event.success,
+ )
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupMenuItems.kt
deleted file mode 100644
index 17dcbb7952..0000000000
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupMenuItems.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-package com.flipcash.app.messenger.internal.screens.profile.edit
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.graphics.painter.Painter
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import com.flipcash.app.menu.FullMenuItem
-import com.flipcash.app.menu.MenuItem
-import com.flipcash.core.R as CoreR
-import com.flipcash.features.messenger.R
-
-/** What the edit list can open. One entry per editable field on `EditChatRequest`. */
-internal sealed interface EditGroupAction {
- data object Picture : EditGroupAction
- data object Name : EditGroupAction
-}
-
-/**
- * The group's picture — node 10187:110373's first row, where the design calls it "Icon".
- *
- * Labelled Picture rather than the design's "Icon": it writes `EditChatRequest.picture`, and
- * picture is the word the rest of the app already uses for it.
- *
- * Wears the currency creator's icon glyph. That flow edits the same two fields on a token that this
- * one edits on a group, so the pair reads as one idea rather than two.
- */
-internal data object EditGroupPicture : FullMenuItem() {
- override val icon: Painter
- @Composable get() = painterResource(CoreR.drawable.ic_currencycreator_icon)
-
- override val name: String
- @Composable get() = stringResource(R.string.title_editGroupPicture)
-
- override val action: EditGroupAction = EditGroupAction.Picture
-}
-
-/**
- * The group's title.
- *
- * Not in node 10187:110373 — the design's four rows are Icon, Membership Card, Description and
- * Social Links — but `EditChatRequest.title` is one of exactly two fields the contract offers, so
- * the row exists and takes the picture row's styling rather than inventing its own.
- *
- * Wears the currency creator's name glyph, for the same reason the picture row wears its icon one.
- */
-internal data object EditGroupName : FullMenuItem() {
- override val icon: Painter
- @Composable get() = painterResource(CoreR.drawable.ic_currencycreator_name)
-
- override val name: String
- @Composable get() = stringResource(R.string.title_editGroupName)
-
- override val action: EditGroupAction = EditGroupAction.Name
-}
-
-/**
- * The edit list, in the order node 10187:110373 puts them in — the picture first.
- *
- * A function rather than a constant so the list has one definition that both the screen and its
- * test read. Membership Card, Description and Social Links are deliberately absent: flipcash2
- * 0.11.0's `EditChatRequest` has `title` and `picture` and nothing else, so a row for any of them
- * would be a control with nothing to write.
- */
-internal fun editGroupItems(): List