From 425ab9a9e9cb20f6b59c98a5b67ef8d3e837876d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 10:41:28 -0400 Subject: [PATCH 1/9] feat(chat): edit a group's name and icon Adds the Edit entry point and the two edits behind it, on top of the editChat/canEdit scaffolding in #1524. The profile's top-right overflow holds one row, Edit, and is drawn only when ViewerState.Permissions.canEdit says so. That field is server-computed and is passed in rather than derived: membership, being the creator, and the chat's kind all look like they answer the question, and a local guess would offer an Edit whose Save comes back DENIED. Edit opens a list of what can be changed. The design node shows four rows; only Icon and Name are built, because the other three have no contract support in flipcash2 0.11.0. EditChat leaves a field unchanged when it is unset, so each edit sends only its own field. titleOnly and pictureOnly name that rule in one place -- built inline at each call site it held by inspection, and an EditChatParameters(title = ..., picture = state.picture) written later, in the shape most object updates take, would overwrite the other field with whatever the screen was holding. Title length is checked in code points, not String.length. protovalidate's max_len counts code points, so 64 emoji measure 128 in Kotlin and a UTF-16 count would refuse titles the contract accepts. On OK the response metadata is persisted directly rather than refetched. The titleChanged/pictureChanged echo carries absolute values, so the stream applying it afterwards is idempotent. The moderation-category wording moves from CreateGroupViewModel to core/moderation so an edit refuses a title in the same words creation does. --- .../com/flipcash/app/core/chat/ChatStep.kt | 27 ++ .../app/core/moderation/ModerationMessages.kt | 38 +++ .../core/src/main/res/values/strings.xml | 19 ++ .../features/messenger/build.gradle.kts | 1 + .../flipcash/app/messenger/ChatFlowScreen.kt | 25 ++ .../screens/profile/GroupProfileMenuItems.kt | 38 +++ .../screens/profile/GroupProfileScreen.kt | 88 +++++- .../screens/profile/edit/ChatTitle.kt | 26 ++ .../profile/edit/EditGroupMenuItems.kt | 63 ++++ .../profile/edit/EditGroupNameScreen.kt | 133 +++++++++ .../profile/edit/EditGroupNameViewModel.kt | 165 ++++++++++ .../profile/edit/EditGroupPictureScreen.kt | 198 ++++++++++++ .../profile/edit/EditGroupPictureViewModel.kt | 281 ++++++++++++++++++ .../screens/profile/edit/EditGroupScreen.kt | 59 ++++ .../screens/profile/edit/PartialEdits.kt | 21 ++ .../profile/GroupProfileOverflowTest.kt | 32 ++ .../screens/profile/edit/ChatTitleTest.kt | 62 ++++ .../screens/profile/edit/PartialEditsTest.kt | 40 +++ .../tipping/internal/CreateGroupViewModel.kt | 25 +- .../flipcash/shared/chat/ChatCoordinator.kt | 19 ++ .../internal/delegates/GroupFeedDelegate.kt | 19 ++ 21 files changed, 1354 insertions(+), 25 deletions(-) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/moderation/ModerationMessages.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitle.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupMenuItems.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameScreen.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureScreen.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/PartialEdits.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileOverflowTest.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitleTest.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/PartialEditsTest.kt 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 0efc8db555..a4d24633c8 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 @@ -82,4 +82,31 @@ sealed interface ChatStep : FlowStep, Parcelable { @Parcelize @Serializable data object GroupProfile : ChatStep + + /** + * Node 10187:110373 — what about the group can be changed, as a list of the things that can. + * + * A pushed screen rather than a sheet, unlike [InviteToGroup] and [MuteChat]: those end on the + * choice they present, while this one is a way through to another screen and has to be + * somewhere back can return to. Carries nothing for the same reason [GroupProfile] does not — + * 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. + */ + @Parcelize + @Serializable + data object EditGroup : ChatStep + + /** The group's title, on the same entry screen shape the profile name uses. */ + @Parcelize + @Serializable + data object EditGroupName : ChatStep + + /** The group's picture — pick, upload, then `EditChat` with the READY blob. */ + @Parcelize + @Serializable + data object EditGroupPicture : ChatStep } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/moderation/ModerationMessages.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/moderation/ModerationMessages.kt new file mode 100644 index 0000000000..4effe9e89b --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/moderation/ModerationMessages.kt @@ -0,0 +1,38 @@ +package com.flipcash.app.core.moderation + +import androidx.annotation.StringRes +import com.flipcash.core.R +import com.flipcash.services.models.ModerationResult + +/** + * The app's wording for a moderation refusal, one message per [ModerationResult.FlaggedCategory]. + * + * Extracted from `CreateGroupViewModel` so the group *edit* flow refuses a title in the same words + * group *creation* does. The strings are deliberately the profile-name ones: they are written + * about a rejected piece of user text rather than about a profile, and the caller supplies the + * title that says which piece it was. + * + * [NONE] means the server flagged the content without naming a category, so it gets the generic + * "try a different one" rather than a reason the response never gave. + */ +@StringRes +fun moderationDescription(category: ModerationResult.FlaggedCategory): Int = + when (category) { + ModerationResult.FlaggedCategory.NONE -> + R.string.error_description_imageNotAllowed + + ModerationResult.FlaggedCategory.OTHER -> + R.string.error_description_profileNameNotAllowedFlaggedOther + + ModerationResult.FlaggedCategory.NSFW -> + R.string.error_description_profileNameNotAllowedFlaggedNsfw + + ModerationResult.FlaggedCategory.IMPERSONATION -> + R.string.error_description_profileNameNotAllowedFlaggedImpersonation + + ModerationResult.FlaggedCategory.MISLEADING -> + R.string.error_description_profileNameNotAllowedFlaggedMisleading + + ModerationResult.FlaggedCategory.SPAM -> + R.string.error_description_profileNameNotAllowedFlaggedSpam + } diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index a3cd5afd1e..8e44f40d72 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1039,6 +1039,25 @@ Something Went Wrong We couldn\'t create this group. Please try again. + + Edit Group + Icon + Name + + Up to 64 characters + + Can\'t Edit This Group + You don\'t have permission to edit this group + + Something Went Wrong + We couldn\'t save your changes. Please try again. + Invite to Join Group Send Invite Link diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index 20760fb700..d14d74190e 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -8,6 +8,7 @@ android { dependencies { implementation(project(":apps:flipcash:shared:analytics")) + implementation(project(":apps:flipcash:shared:blob")) implementation(project(":apps:flipcash:shared:blocklist")) implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:chat-ui")) 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 fb6b7c5f21..1ca0210814 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 @@ -28,6 +28,9 @@ import com.flipcash.app.messenger.internal.screens.cash.ChatInitPaymentSheet 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.EditGroupNameScreen +import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupPictureScreen +import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupScreen import com.flipcash.shared.chat.ui.rememberIsMuted import com.getcode.navigation.annotatedEntry import com.getcode.navigation.core.LocalCodeNavigator @@ -105,6 +108,15 @@ private fun chatEntryProvider( annotatedEntry { FlowGroupProfileScreen() } + annotatedEntry { + EditGroupScreen() + } + annotatedEntry { + FlowEditGroupNameScreen() + } + annotatedEntry { + FlowEditGroupPictureScreen() + } } @Composable @@ -308,3 +320,16 @@ private fun FlowChatProfileScreen(participant: ChatParticipant) { private fun FlowGroupProfileScreen() { GroupProfileScreen(flowSharedViewModel()) } + +// Both 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. +@Composable +private fun FlowEditGroupNameScreen() { + EditGroupNameScreen(flowSharedViewModel()) +} + +@Composable +private fun FlowEditGroupPictureScreen() { + EditGroupPictureScreen(flowSharedViewModel()) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt index 9c4c0fc4b4..59946e410f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.screens.profile import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Logout +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Flag import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.runtime.Composable @@ -9,6 +10,7 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.stringResource import com.flipcash.app.menu.FullMenuItem +import com.flipcash.app.menu.MenuItem import com.flipcash.features.messenger.R /** @@ -24,6 +26,9 @@ internal sealed interface GroupProfileAction { data object Mute : GroupProfileAction data object Leave : GroupProfileAction data object Report : GroupProfileAction + + /** Opens node 10187:110373's list of what about the group can be changed. */ + data object Edit : GroupProfileAction } /** @@ -68,3 +73,36 @@ internal data object LeaveChat : FullMenuItem() { /** The group's mute row. Shared definition; see [MuteChatItem]. */ internal val MuteChat = MuteChatItem(GroupProfileAction.Mute) + +/** + * The group's edit row, in the profile's top-right overflow rather than in the list below it. + * + * A pencil, the same glyph the message overflow uses for editing a message — one verb, one icon. + * Not `ic_group_3`, which names a group rather than the act of changing one. + * + * Node 10187:110373 is the screen this opens; the overflow it hangs from is the message + * long-press menu's shape reused (see `ChatTopBar.MessageOverflow`). + */ +internal data object EditGroup : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.Edit) + + override val name: String + @Composable get() = stringResource(R.string.action_edit) + + override val action: GroupProfileAction = GroupProfileAction.Edit +} + +/** + * What the profile's top-right overflow holds, for a viewer the server says may edit. + * + * [canEdit] comes from `ViewerState.Permissions` and is server-computed. It is passed in rather + * than derived here on purpose: membership, being the creator, and the chat's kind all *look* like + * they answer the question and none of them is the answer the server gives, so a local guess would + * offer an Edit that `EditChat` then refuses with `DENIED`. + * + * Returns a list so the overflow has nothing to show — and so draws nothing — when the answer is + * no. A single-item menu is still a menu; an empty one is not a button. + */ +internal fun groupProfileOverflowItems(canEdit: Boolean): List> = + if (canEdit) listOf(EditGroup) else emptyList() 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 2f99654c8c..5a6aeea736 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 @@ -7,15 +7,24 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.chat.ChatStep @@ -32,6 +41,8 @@ import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ViewerState import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme +import com.getcode.theme.extraLarge +import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.theme.CodeScaffold import kotlinx.coroutines.flow.filterIsInstance @@ -64,7 +75,26 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { CodeScaffold( topBar = { - AppBarWithTitle(onBackIconClicked = { flowNavigator.back() }) + AppBarWithTitle( + onBackIconClicked = { flowNavigator.back() }, + endContent = { + GroupProfileOverflow( + // Server-computed, and absent entirely for a non-member, so an + // unresolved viewer state reads as "may not edit" rather than as a + // permission to be re-derived here. + canEdit = state.viewerState?.permissions?.canEdit == true, + onAction = { action -> + when (action) { + GroupProfileAction.Edit -> + flowNavigator.navigateTo(ChatStep.EditGroup) + // The overflow carries the edit row alone; the rest of + // [GroupProfileAction] is the list's. + else -> Unit + } + }, + ) + }, + ) }, ) { innerPadding -> MenuList( @@ -118,12 +148,68 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { AppRoute.Messaging.Report(ReportSubject.Chat(chatId)) ) } + // Edit is an overflow row, not a body row, so this arm is unreachable today. + // It routes to the same step the overflow does so that "what Edit means" has + // one definition, rather than going stale if the row ever moves into the body. + GroupProfileAction.Edit -> flowNavigator.navigateTo(ChatStep.EditGroup) } }, ) } } +/** + * The profile's top-right overflow — one row, Edit, and only for a viewer who may use it. + * + * Draws nothing at all when the list is empty rather than a disabled button: an overflow that + * opens on nothing is worse than no overflow, and `canEdit` is stable for the life of the screen + * in every case but a permission being revoked under it. + * + * Shape is the message long-press menu's, from `ChatTopBar.MessageOverflow` — same surface colour, + * same corner, same drop clear of the button — so the two menus in this feature read as one + * control rather than two. + */ +@Composable +private fun GroupProfileOverflow( + canEdit: Boolean, + onAction: (GroupProfileAction) -> Unit, +) { + val items = groupProfileOverflowItems(canEdit) + if (items.isEmpty()) return + + var expanded by remember { mutableStateOf(false) } + Box { + AppBarDefaults.Overflow( + modifier = Modifier.testTag("action_group_profile_overflow"), + onClick = { expanded = true }, + ) + DropdownMenu( + expanded = expanded, + containerColor = CodeTheme.colors.brandLight, + shape = CodeTheme.shapes.extraLarge, + offset = DpOffset(x = 0.dp, y = CodeTheme.dimens.grid.x2), + onDismissRequest = { expanded = false }, + ) { + items.forEach { item -> + DropdownMenuItem( + modifier = Modifier.testTag("action_edit_group"), + text = { + Text( + text = item.name, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + ) + }, + onClick = { + expanded = false + onAction(item.action) + }, + ) + } + } + } +} + @VisibleForTesting @Composable internal fun GroupProfileHeader( 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 new file mode 100644 index 0000000000..3128415c47 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitle.kt @@ -0,0 +1,26 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +/** + * `EditChatRequest.Title.value` — `min_len 1, max_len 64`, checked here before the call. + * + * The bound is on the *trimmed* title, and the trimmed title is what gets sent: surrounding + * whitespace is invisible in every place a group title is drawn, so counting it would let a title + * that looks empty through and a title that looks short fail. + * + * Length is counted in code points, not [String.length]. protovalidate's `max_len` on a string + * counts Unicode code points, while Kotlin counts UTF-16 units, so a title of 40 emoji measures 80 + * here and 40 at the server. Using [String.length] would refuse titles the contract accepts, and + * would do it only for non-BMP characters — the case least likely to be noticed. + */ +internal object ChatTitle { + const val MIN_LENGTH = 1 + const val MAX_LENGTH = 64 + + /** The value to send for [input] — trimmed, as [isValid] measures it. */ + fun normalize(input: CharSequence): String = input.toString().trim() + + fun lengthOf(input: CharSequence): Int = + normalize(input).let { it.codePointCount(0, it.length) } + + 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/EditGroupMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupMenuItems.kt new file mode 100644 index 0000000000..0cf7362b38 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupMenuItems.kt @@ -0,0 +1,63 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material.icons.outlined.TextFields +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource +import com.flipcash.app.menu.FullMenuItem +import com.flipcash.app.menu.MenuItem +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". + * + * "Icon" is the row's own label in the design and is kept, even though the field it writes is + * `EditChatRequest.Picture` and the rest of the app calls it a picture: the label names what the + * user sees at the head of the group, not the wire field behind it. + */ +internal data object EditGroupIcon : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.Image) + + override val name: String + @Composable get() = stringResource(R.string.title_editGroupIcon) + + 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 Icon row's styling rather than inventing its own. + */ +internal data object EditGroupName : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.TextFields) + + 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> = + listOf(EditGroupIcon, EditGroupName) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameScreen.kt new file mode 100644 index 0000000000..4fea85bc9a --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameScreen.kt @@ -0,0 +1,133 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import android.os.Parcelable +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.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.input.KeyboardType +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.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 title, behind node 10187:110373's Name row. + * + * Takes [chatViewModel] for the chat it is editing and the title it starts from — the same reason + * `GroupProfileScreen` does, and the reason this step carries no arguments — and keeps the edit + * itself in its own nav-entry-scoped view model, so backing out drops the draft with the entry. + */ +@Composable +internal fun EditGroupNameScreen(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 rather than Unit: the seed has to wait for the group to resolve, and must + // not run again afterwards or it would overwrite the edit in progress. + LaunchedEffect(group?.chatId) { + val resolved = group ?: return@LaunchedEffect + viewModel.dispatchEvent( + EditGroupNameViewModel.Event.Initialize( + chatId = resolved.chatId, + title = resolved.groupTitle.orEmpty(), + ) + ) + } + + // Only OK gets here. A refusal — a moderated title above all — keeps the user on the screen + // with what they typed, which is the only state an amendment can start from. + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.back() } + .launchIn(this) + } + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_editGroupName), + onBackIconClicked = { keyboard.hideIfVisible { flowNavigator.back() } }, + ) + }, + bottomBar = { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .navigationBarsPadding() + .padding( + top = CodeTheme.dimens.grid.x6, + bottom = CodeTheme.dimens.grid.x3, + ).imePadding(), + text = stringResource(R.string.action_save), + enabled = state.canSubmit, + isLoading = state.processingState.loading, + isSuccess = state.processingState.success, + onClick = { + keyboard.hideIfVisible { + viewModel.dispatchEvent(EditGroupNameViewModel.Event.SubmitTitle) + } + }, + ) + }, + ) { padding -> + val focusRequester = remember { FocusRequester() } + Column( + modifier = Modifier + .padding(padding) + .padding(horizontal = CodeTheme.dimens.inset) + ) { + DisplayTextInput( + state = state.titleFieldState, + placeholder = stringResource(R.string.hint_groupName), + sublabel = stringResource(R.string.subtitle_groupNameLength), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + ), + onKeyboardAction = { + keyboard.hideIfVisible { + viewModel.dispatchEvent(EditGroupNameViewModel.Event.SubmitTitle) + } + }, + ) + } + + LaunchedEffect(Unit) { focusRequester.requestFocus() } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt new file mode 100644 index 0000000000..80d3087d00 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt @@ -0,0 +1,165 @@ +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.app.core.moderation.moderationDescription +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.EditChatError +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import com.getcode.view.LoadingSuccessState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +/** + * The group-title edit behind node 10187:110373's Name row. + * + * Shaped after `NameEntryViewModel`, which cannot itself be reused: it is bound to the signed-in + * user's profile at both ends — seeding from `UserManager` and writing through + * `ProfileController.setDisplayName` — so what carries over is the screen's shape and its + * moderation wording, not the view model. + * + * Seeded by the screen from the conversation's own state rather than by fetching: the title is + * already on `ChatSubject.Group`, kept current by the roster and the event stream. + */ +@HiltViewModel +class EditGroupNameViewModel @Inject constructor( + private val chatCoordinator: ChatCoordinator, + private val resources: ResourceHelper, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent +) { + data class State( + val titleFieldState: TextFieldState = TextFieldState(), + val chatId: ChatId? = null, + /** The stored title: the baseline an edit is measured against. */ + val savedTitle: String = "", + val processingState: LoadingSuccessState = LoadingSuccessState(), + ) { + /** `min_len 1, max_len 64`, counted as [ChatTitle] counts it. */ + val isValid: Boolean + get() = ChatTitle.isValid(titleFieldState.text) + + /** Nothing to send when the title is what it already was. */ + val isChanged: Boolean + get() = ChatTitle.normalize(titleFieldState.text) != savedTitle.trim() + + val canSubmit: Boolean + get() = isValid && isChanged && processingState.isIdle && chatId != null + } + + sealed interface Event { + /** The screen handing over the chat this is editing, and the title it currently has. */ + data class Initialize(val chatId: ChatId, val title: String) : Event + + data object SubmitTitle : Event + + data class UpdateProcessingState( + val loading: Boolean = false, + val success: Boolean = false, + ) : Event + + /** The edit was accepted and stored; the screen can leave. */ + data object OnTitleAccepted : Event + } + + init { + eventFlow + .filterIsInstance() + // Seeds once. A later title arriving from the stream while the user is mid-edit would + // otherwise overwrite what they are typing, and this screen exists for exactly as long + // as one edit — there is nothing to reconcile with. + .onEach { event -> + stateFlow.value.titleFieldState.setTextAndPlaceCursorAtEnd(event.title) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + val state = stateFlow.value + val chatId = state.chatId ?: return@onEach + // Re-checked rather than trusted from the button's enabled state: the keyboard's + // Done action reaches here too. + if (!state.isValid) return@onEach + + dispatchEvent(Event.UpdateProcessingState(loading = true)) + + // Title only. The picture field stays unset so the server leaves the picture + // alone — a full-object write here would clear it. + val result = chatCoordinator.editChat( + chatId = chatId, + parameters = titleOnly(state.titleFieldState.text), + ) + + result.onSuccess { + dispatchEvent(Event.UpdateProcessingState(success = true)) + viewModelScope.launch { + delay(500.milliseconds) + dispatchEvent(Event.OnTitleAccepted) + dispatchEvent(Event.UpdateProcessingState()) + } + }.onFailure { cause -> + dispatchEvent(Event.UpdateProcessingState()) + announceEditFailure(cause) + } + }.launchIn(viewModelScope) + } + + /** + * `EditChatResponse.Result`, less `OK`. + * + * Every arm leaves the user on this screen with the field as they typed it — a refused title + * is one to amend, and `TITLE_MODERATED` in particular is only actionable if what was rejected + * is still in front of them. + */ + private fun announceEditFailure(cause: Throwable) { + when (cause) { + is EditChatError.TitleModerated -> BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_groupTitleNotAllowed), + message = resources.getString(moderationDescription(cause.category)), + ) + + is EditChatError.Denied -> BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_groupEditDenied), + message = resources.getString(R.string.error_description_groupEditDenied), + ) + + else -> BottomBarManager.showError( + title = resources.getString(R.string.error_title_groupEditFailed), + message = resources.getString(R.string.error_description_groupEditFailed), + ) + } + } + + internal companion object { + val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + is Event.Initialize -> { state -> + state.copy(chatId = event.chatId, savedTitle = event.title) + } + + Event.SubmitTitle -> { state -> state } + Event.OnTitleAccepted -> { 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/EditGroupPictureScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureScreen.kt new file mode 100644 index 0000000000..63931f645e --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureScreen.kt @@ -0,0 +1,198 @@ +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.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.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 picture, behind node 10187:110373's Icon row. + * + * Laid out as `PhotoSelectionScreen` is — a round well that opens the picker, the stored picture + * inside it until a pick replaces it, Save underneath — so the two picture edits in the app are + * the same screen to use. The view model behind it differs because the write does: + * `EditChat` against a group rather than the signed-in user's own profile. + */ +@Composable +internal fun EditGroupPictureScreen(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( + EditGroupPictureViewModel.Event.Initialize( + chatId = resolved.chatId, + picture = resolved.picture, + title = resolved.groupTitle.orEmpty(), + ) + ) + } + + 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(EditGroupPictureViewModel.Event.OnImageSelected(uri)) + } else { + trace(tag = TAG, message = "No image selected", type = TraceType.User) + } + } + + CodeScaffold( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_editGroupIcon), + titleAlignment = Alignment.CenterHorizontally, + // The pick is dropped by leaving: the view model is scoped to this nav entry, so + // popping it takes the draft with it and the stored picture stands. + onBackIconClicked = { flowNavigator.back() }, + ) + }, + bottomBar = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset), + ) { + 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(EditGroupPictureViewModel.Event.SubmitPicture) + }, + ) + } + }, + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(150.dp) + .background(color = CodeTheme.colors.divider, shape = CircleShape) + .clip(CircleShape) + .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 group's stored picture, so the screen opens on the + // current icon rather than an empty well. Save stays disabled until a pick + // is made, so this cannot be mistaken for one. + state.savedPicture != null -> { + ContactAvatar( + image = state.savedPicture, + displayName = state.groupTitle, + access = BlobAccessContext.Owned, + modifier = Modifier.fillMaxSize(), + ) + } + + else -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + tint = White50, + modifier = Modifier.size(48.dp), + ) + } + } + } + } + } + } + } +} + +private const val TAG = "EditGroupPicture" diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt new file mode 100644 index 0000000000..1be95af903 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt @@ -0,0 +1,281 @@ +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.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.services.models.blob.UploadPolicy +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 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 javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +/** + * The group-picture edit behind node 10187:110373's Icon row. + * + * 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 EditGroupPictureViewModel @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 +) { + data class State( + val chatId: ChatId? = null, + /** The group's stored picture — what the well shows until a pick replaces it. */ + val savedPicture: MediaItem? = null, + val groupTitle: String = "", + /** 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 picture 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 picture: MediaItem?, + val title: String, + ) : 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 + data object SubmitPicture : Event + data class UpdateProcessingState( + val loading: Boolean = false, + val success: Boolean = false, + ) : Event + + data object OnPictureAccepted : 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_picture", + )) { + 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 { 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 + } + + // Picture only. The title field stays unset so the server leaves the title alone. + chatCoordinator.editChat( + chatId = chatId, + parameters = pictureOnly(blobId), + ).onSuccess { + dispatchEvent(Event.UpdateProcessingState(success = true)) + delay(500.milliseconds) + dispatchEvent(Event.OnPictureAccepted) + 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) { + when (cause) { + // 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. + is EditChatError.PictureBlobNotAccepted -> { + dispatchEvent(Event.DiscardPendingImage) + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_imageNotAllowed), + message = resources.getString(R.string.error_description_imageNotAllowed), + ) + } + + is EditChatError.Denied -> BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_groupEditDenied), + message = resources.getString(R.string.error_description_groupEditDenied), + ) + + else -> BottomBarManager.showError( + title = resources.getString(R.string.error_title_groupEditFailed), + message = resources.getString(R.string.error_description_groupEditFailed), + ) + } + } + + 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, + savedPicture = event.picture, + groupTitle = event.title, + ) + } + + 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.SubmitPicture -> { state -> state } + Event.OnPictureAccepted -> { 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/EditGroupScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt new file mode 100644 index 0000000000..9876535875 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt @@ -0,0 +1,59 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import android.os.Parcelable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.menu.MenuList +import com.flipcash.features.messenger.R +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeScaffold + +/** + * The group's edit list — node 10187:110373. + * + * Two rows, because the contract has two fields. The design's other rows (Membership Card, + * Description, Social Links) are left out rather than stubbed: `EditChatRequest` in flipcash2 + * 0.11.0 carries `title` and `picture` only, so each of them would be a row that opens onto + * nothing the server would accept. + * + * The design titles this screen "Edit Community"; it is titled "Edit Group" here because "Group" + * is what the rest of the app calls this thing in front of the user — "New Public Group", "Group + * Name" — and one screen switching vocabulary mid-flow is worse than differing from the mock. + * + * Stateless beyond navigation: both rows own their own edit and their own failure, so there is + * nothing here to hold. The list itself is not gated — `canEdit` gates the door to it on + * [com.flipcash.app.messenger.internal.screens.profile.GroupProfileScreen], and the server + * re-checks on the write regardless. + */ +@Composable +internal fun EditGroupScreen() { + val flowNavigator = rememberFlowNavigator() + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_editGroup), + onBackIconClicked = { flowNavigator.back() }, + ) + }, + ) { innerPadding -> + MenuList( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + items = editGroupItems(), + showChevrons = true, + onItemClick = { item -> + when (item.action) { + EditGroupAction.Picture -> flowNavigator.navigateTo(ChatStep.EditGroupPicture) + EditGroupAction.Name -> flowNavigator.navigateTo(ChatStep.EditGroupName) + } + }, + ) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/PartialEdits.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/PartialEdits.kt new file mode 100644 index 0000000000..64a8b753dd --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/PartialEdits.kt @@ -0,0 +1,21 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.EditChatParameters + +/** + * The two edits this screen can make, each named so the "send only what changed" rule is a thing + * the code states rather than a thing every call site has to remember. + * + * `EditChatRequest` leaves a field unchanged when it is unset, so a partial update is the whole + * mechanism: [titleOnly] must not carry a picture and [pictureOnly] must not carry a title. + * Building the parameters inline at each call site made that a convention holding by inspection — + * and an `EditChatParameters(title = ..., picture = state.picture)` written later, in the shape + * most object updates take, would silently overwrite the other field with whatever the screen + * happened to be holding. + */ +internal fun titleOnly(title: CharSequence): EditChatParameters = + EditChatParameters(title = ChatTitle.normalize(title)) + +internal fun pictureOnly(blobId: BlobId): EditChatParameters = + EditChatParameters(picture = blobId) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileOverflowTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileOverflowTest.kt new file mode 100644 index 0000000000..fb497c7152 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileOverflowTest.kt @@ -0,0 +1,32 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Who is offered the group's Edit row. + * + * `canEdit` is computed by the server and arrives on `ViewerState.Permissions`. The reason this is + * worth a test of its own is that several local signals — being a member, having created the chat, + * the chat being a group at all — look like they answer the question and none of them is the + * answer. Offering Edit on a local guess produces a row that opens a screen whose Save then comes + * back `DENIED`, which reads to the user as the app being broken rather than as a permission. + */ +class GroupProfileOverflowTest { + + @Test + fun `a viewer who may edit is offered exactly the Edit row`() { + val items = groupProfileOverflowItems(canEdit = true) + + assertEquals(1, items.size) + assertEquals(GroupProfileAction.Edit, items.single().action) + } + + @Test + fun `a viewer who may not edit is offered nothing`() { + // Empty rather than disabled: the overflow button itself is drawn only when this has + // something in it, so no-permission means no button, not a button that opens an empty menu. + assertTrue(groupProfileOverflowItems(canEdit = false).isEmpty()) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitleTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitleTest.kt new file mode 100644 index 0000000000..d4dd0bd34f --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitleTest.kt @@ -0,0 +1,62 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The client half of `EditChatRequest.Title`'s `min_len 1, max_len 64`. + * + * The server enforces it too, so the stake here is not correctness of the write but what the user + * sees: without this the Save button is live on a title the server will refuse, and the refusal + * comes back as a generic failure rather than as the screen keeping the field for them to fix. + */ +class ChatTitleTest { + + @Test + fun `a single character is a valid title`() { + assertTrue(ChatTitle.isValid("a")) + } + + @Test + fun `exactly 64 characters is valid, 65 is not`() { + assertTrue(ChatTitle.isValid("a".repeat(64))) + assertFalse(ChatTitle.isValid("a".repeat(65))) + } + + @Test + fun `an empty title is refused`() { + assertFalse(ChatTitle.isValid("")) + } + + @Test + fun `a title of only whitespace is refused`() { + // It has a length, but every place the title is drawn would render it blank, so counting + // the spaces would let an apparently-empty group name through. + assertFalse(ChatTitle.isValid(" ")) + } + + @Test + fun `surrounding whitespace does not count toward the limit`() { + // 64 real characters padded either side still fits, because the padding is trimmed before + // it is measured and before it is sent. + assertTrue(ChatTitle.isValid(" " + "a".repeat(64) + " ")) + } + + @Test + fun `the value sent is the trimmed one`() { + assertEquals("Book Club", ChatTitle.normalize(" Book Club ")) + } + + @Test + fun `length is counted in code points, not UTF-16 units`() { + // Each of these is one code point and two Kotlin chars. 64 of them is exactly the limit at + // the server; measuring with String.length would call it 128 and refuse a legal title. + val emoji = "🎉" // party popper + assertEquals(128, emoji.repeat(64).length) + assertEquals(64, ChatTitle.lengthOf(emoji.repeat(64))) + assertTrue(ChatTitle.isValid(emoji.repeat(64))) + assertFalse(ChatTitle.isValid(emoji.repeat(65))) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/PartialEditsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/PartialEditsTest.kt new file mode 100644 index 0000000000..5d07226cef --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/PartialEditsTest.kt @@ -0,0 +1,40 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import com.flipcash.services.models.chat.BlobId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * `EditChat` leaves a field unchanged when it is unset, which makes an over-filled request + * destructive rather than merely wasteful: renaming a group while also sending the picture field + * would rewrite the picture, and sending it as null would clear it. + * + * These assert the one property that matters at the call site — each edit carries its own field + * and nothing else. + */ +class PartialEditsTest { + + @Test + fun `renaming sends a title and no picture`() { + val parameters = titleOnly("Book Club") + + assertEquals("Book Club", parameters.title) + assertNull(parameters.picture, "a rename must not touch the group's picture") + } + + @Test + fun `renaming sends the trimmed title`() { + assertEquals("Book Club", titleOnly(" Book Club ").title) + } + + @Test + fun `changing the picture sends a blob and no title`() { + val blob = BlobId(byteArrayOf(1, 2, 3)) + + val parameters = pictureOnly(blob) + + assertEquals(blob, parameters.picture) + assertNull(parameters.title, "a picture change must not touch the group's name") + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt index f9ad7c9b71..53d33043d6 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt @@ -8,12 +8,12 @@ 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.app.core.ui.ConfirmationStyle import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.features.tipping.R import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.models.BlobRejectedException -import com.flipcash.services.models.ModerationResult import com.flipcash.services.models.StartChatError import com.flipcash.services.models.chat.BlobId import com.flipcash.services.models.chat.ChatMetadata @@ -496,29 +496,6 @@ internal class CreateGroupViewModel @Inject constructor( } } - /** The app's existing moderation-category wording, as the username and photo screens map it. */ - @StringRes - private fun moderationDescription(category: ModerationResult.FlaggedCategory): Int = - when (category) { - ModerationResult.FlaggedCategory.NONE -> - R.string.error_description_imageNotAllowed - - ModerationResult.FlaggedCategory.OTHER -> - R.string.error_description_profileNameNotAllowedFlaggedOther - - ModerationResult.FlaggedCategory.NSFW -> - R.string.error_description_profileNameNotAllowedFlaggedNsfw - - ModerationResult.FlaggedCategory.IMPERSONATION -> - R.string.error_description_profileNameNotAllowedFlaggedImpersonation - - ModerationResult.FlaggedCategory.MISLEADING -> - R.string.error_description_profileNameNotAllowedFlaggedMisleading - - ModerationResult.FlaggedCategory.SPAM -> - R.string.error_description_profileNameNotAllowedFlaggedSpam - } - companion object { @VisibleForTesting internal val updateStateForEvent: (Event) -> (State.() -> State) = { event -> 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 bc76c5b11b..b79281d092 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 @@ -11,6 +11,7 @@ import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer @@ -338,6 +339,24 @@ interface GroupOperations { idempotencyKey: IdempotencyKey, ): Result + /** + * Applies [parameters] to [chatId] and stores the chat the server answers with. + * + * A partial update: [EditChatParameters] leaves every unset field alone, so editing the title + * cannot clear the picture. Requires `ViewerState.Permissions.canEdit`, which is the server's + * to compute — a caller without it gets `DENIED` rather than a local refusal. + * + * The post-edit metadata is persisted from the response rather than refetched, and the + * `titleChanged` / `pictureChanged` echo that follows carries the same absolute values and is + * applied unconditionally, so the two converge instead of compounding. Suppressing the echo + * would be the wrong fix: it cannot be told apart from a *different* editor's change arriving + * in the same window, which is a change this device does need. + */ + suspend fun editChat( + chatId: ChatId, + parameters: EditChatParameters, + ): Result + /** Joins [chatId], caching the chat so it is in the list before the next sync. */ suspend fun join(chatId: ChatId): Result 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 e1b409ca68..823dccb453 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 @@ -6,6 +6,7 @@ import com.flipcash.app.persistence.sources.ChatMetadataDataSource import com.flipcash.services.controllers.ChatController import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.RosterChange import com.flipcash.services.models.chat.StartChatParameters @@ -131,6 +132,24 @@ class GroupFeedDelegate @Inject constructor( } } + /** + * Edits [chatId] and stores the post-edit metadata the server returns. + * + * [persist] rather than a refetch: `EditChat` answers `OK` with the chat as it now stands, so + * a second round trip could only return the same thing later. No membership write — unlike + * [create] and [join], an edit does not change who the caller is to the chat. + */ + override suspend fun editChat( + chatId: ChatId, + parameters: EditChatParameters, + ): Result { + return chatController.editChat(chatId, parameters) + .onSuccess { metadata -> persist(listOf(metadata)) } + .onFailure { + trace(tag = TAG, message = "Edit failed for $chatId", type = TraceType.Error) + } + } + /** * Joins [chatId], caching the chat the server returns so the list has it before any sync runs. */ From c011afd75556426b2a8c06038abcc43f9f1ee7a7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 11:57:35 -0400 Subject: [PATCH 2/9] fix(messenger): give the group profile overflow a leading icon and cover its anchor DropdownMenu lays out to a 112dp minimum width. "Edit" fills about 24dp of that at a 13dp inset, so the menu rendered as a mostly empty rounded rectangle with a word in one corner. MenuItem already carries an icon for this action; the menu just never drew it. Passing it as leadingIcon fills the leading space and gives the trailing space something to measure against, matching the rows on the screen behind the menu at the same 24dp. The menu also dropped clear of the overflow button, which pushed its first row toward the middle of the screen and left the button stranded above it. Chrome's toolbar overflow opens over its button instead. Offsetting by the anchor's measured height does the same here; AppBarDefaults.Overflow sizes its own circle, so the pull-up is read from the anchor rather than hardcoded. ChatTopBar's message menu still drops below its anchor. --- .../screens/profile/GroupProfileScreen.kt | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) 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 5a6aeea736..b2c28067a9 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 @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.screens.profile import android.os.Parcelable import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -21,6 +22,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.unit.DpOffset @@ -158,6 +162,12 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { } } +/** + * The overflow row's leading glyph, matching `ListItem`'s, so the one row inside the menu and the + * rows on the screen behind it line their icons up at the same size. + */ +private val OverflowIconSize = 24.dp + /** * The profile's top-right overflow — one row, Edit, and only for a viewer who may use it. * @@ -165,9 +175,17 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { * opens on nothing is worse than no overflow, and `canEdit` is stable for the life of the screen * in every case but a permission being revoked under it. * - * Shape is the message long-press menu's, from `ChatTopBar.MessageOverflow` — same surface colour, - * same corner, same drop clear of the button — so the two menus in this feature read as one - * control rather than two. + * Surface colour and corner are the message long-press menu's, from `ChatTopBar.MessageOverflow`, + * so the two menus in this feature read as one control. The vertical placement is not: this one + * opens *over* its button the way Chrome's toolbar overflow does, rather than below it. A menu + * dropped clear of a top-right button pushes its first row toward the middle of the screen and + * leaves the button stranded above it; covering the button puts the row where the thumb already + * is. `ChatTopBar`'s menu still drops below, which is the remaining inconsistency. + * + * The row is icon-led for the same reason the rows below it are. `DropdownMenu` is laid out to a + * 112dp minimum width, which a label as short as "Edit" leaves more than half empty; the glyph + * fills the leading space the trailing space is measured against, so the menu reads as a row + * rather than as a blank rounded rectangle with a word in the corner. */ @Composable private fun GroupProfileOverflow( @@ -178,7 +196,11 @@ private fun GroupProfileOverflow( if (items.isEmpty()) return var expanded by remember { mutableStateOf(false) } - Box { + // Measured rather than assumed: `AppBarDefaults.Overflow` sizes its own circle, so the pull-up + // that lands the menu's top edge on the button's top has to come from the anchor itself. + var anchorHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + Box(modifier = Modifier.onSizeChanged { anchorHeight = with(density) { it.height.toDp() } }) { AppBarDefaults.Overflow( modifier = Modifier.testTag("action_group_profile_overflow"), onClick = { expanded = true }, @@ -187,12 +209,20 @@ private fun GroupProfileOverflow( expanded = expanded, containerColor = CodeTheme.colors.brandLight, shape = CodeTheme.shapes.extraLarge, - offset = DpOffset(x = 0.dp, y = CodeTheme.dimens.grid.x2), + offset = DpOffset(x = 0.dp, y = -anchorHeight), onDismissRequest = { expanded = false }, ) { items.forEach { item -> DropdownMenuItem( modifier = Modifier.testTag("action_edit_group"), + leadingIcon = { + Image( + modifier = Modifier.size(OverflowIconSize), + painter = item.icon, + colorFilter = ColorFilter.tint(CodeTheme.colors.textMain), + contentDescription = null, + ) + }, text = { Text( text = item.name, From eac001e2ec84663ec92deefc9058a3fb1615a56b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 12:26:39 -0400 Subject: [PATCH 3/9] feat(messenger): confirm a group name or photo change before sending it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Save now raises a BottomBarManager prompt, and the prompt's action is what calls EditChat — the same shape UserProfileViewModel uses to confirm these two edits on the user's own profile. Both changes are visible to everyone in the group, which is the case for asking first. The gate is a view model event rather than screen state so a test can hold it: SaveClicked sets nothing in motion, and only the confirmation dispatches SubmitTitle/SubmitPicture. The name screen's keyboard Done routes through the same event and so re-reads canSubmit, which the Save button draws itself from but Done never consulted. Both view models now take their event dispatcher from DispatcherProvider. BaseViewModel otherwise publishes on Dispatchers.Default, which no test scheduler can drain, so Initialize was overwriting a draft the test had already typed. Titles alongside: the Icon row is Photo and its screen is Set Photo, Edit Group is centred to match the screens under it, and the name screen drops its title — the field is the screen, and the row that opened it said Name. --- .../core/src/main/res/values/strings.xml | 16 +- .../profile/edit/EditGroupMenuItems.kt | 2 +- .../profile/edit/EditGroupNameScreen.kt | 7 +- .../profile/edit/EditGroupNameViewModel.kt | 44 ++++- .../profile/edit/EditGroupPictureScreen.kt | 4 +- .../profile/edit/EditGroupPictureViewModel.kt | 37 +++- .../screens/profile/edit/EditGroupScreen.kt | 2 + .../profile/edit/EditGroupConfirmationTest.kt | 180 ++++++++++++++++++ 8 files changed, 282 insertions(+), 10 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupConfirmationTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 8e44f40d72..f5188bb5cc 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1045,10 +1045,22 @@ The node lists four rows — Icon, Membership Card, Description, Social Links. Only Icon and Name are here: the other three have no field on EditChatRequest in flipcash2 0.11.0. Name - is not in the node at all and takes the Icon row's styling. --> + is not in the node at all and takes the Icon row's styling. The node's Icon row ships as + Photo, which is what the row actually changes and what the rest of the app calls it. --> Edit Group - Icon + Photo Name + + Set Photo + + + Change Group Name? + Everyone in this group will see it as \"%1$s\". + Change Name + Change Group Photo? + Everyone in this group will see the new photo. + Change Photo Up to 64 characters 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 index 0cf7362b38..3f803db1cc 100644 --- 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 @@ -29,7 +29,7 @@ internal data object EditGroupIcon : FullMenuItem() { @Composable get() = rememberVectorPainter(Icons.Outlined.Image) override val name: String - @Composable get() = stringResource(R.string.title_editGroupIcon) + @Composable get() = stringResource(R.string.title_editGroupPhoto) override val action: EditGroupAction = EditGroupAction.Picture } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameScreen.kt index 4fea85bc9a..964ac51a41 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameScreen.kt @@ -75,8 +75,9 @@ internal fun EditGroupNameScreen(chatViewModel: ChatViewModel) { CodeScaffold( topBar = { + // No title. The field is the screen, and the row that opened it already said Name; + // the bar is here for the back control. AppBarWithTitle( - title = stringResource(R.string.title_editGroupName), onBackIconClicked = { keyboard.hideIfVisible { flowNavigator.back() } }, ) }, @@ -96,7 +97,7 @@ internal fun EditGroupNameScreen(chatViewModel: ChatViewModel) { isSuccess = state.processingState.success, onClick = { keyboard.hideIfVisible { - viewModel.dispatchEvent(EditGroupNameViewModel.Event.SubmitTitle) + viewModel.dispatchEvent(EditGroupNameViewModel.Event.SaveClicked) } }, ) @@ -122,7 +123,7 @@ internal fun EditGroupNameScreen(chatViewModel: ChatViewModel) { ), onKeyboardAction = { keyboard.hideIfVisible { - viewModel.dispatchEvent(EditGroupNameViewModel.Event.SubmitTitle) + viewModel.dispatchEvent(EditGroupNameViewModel.Event.SaveClicked) } }, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt index 80d3087d00..eba7686f8f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt @@ -5,9 +5,11 @@ import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.lifecycle.viewModelScope import com.flipcash.app.core.moderation.moderationDescription import com.flipcash.features.messenger.R +import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.EditChatError import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel @@ -34,11 +36,15 @@ import kotlin.time.Duration.Companion.milliseconds */ @HiltViewModel class EditGroupNameViewModel @Inject constructor( + dispatchers: DispatcherProvider, private val chatCoordinator: ChatCoordinator, private val resources: ResourceHelper, ) : BaseViewModel( initialState = State(), - updateStateForEvent = updateStateForEvent + 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 titleFieldState: TextFieldState = TextFieldState(), @@ -63,6 +69,13 @@ class EditGroupNameViewModel @Inject constructor( /** The screen handing over the chat this is editing, and the title it currently has. */ data class Initialize(val chatId: ChatId, val title: String) : Event + /** + * Save, pressed — or the keyboard's Done. The rename is only proposed here: what sends it + * is [SubmitTitle], dispatched by the confirmation's own action. + */ + data object SaveClicked : Event + + /** The confirmed rename. Reachable only through the prompt [SaveClicked] raises. */ data object SubmitTitle : Event data class UpdateProcessingState( @@ -84,6 +97,34 @@ class EditGroupNameViewModel @Inject constructor( stateFlow.value.titleFieldState.setTextAndPlaceCursorAtEnd(event.title) }.launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .onEach { + val state = stateFlow.value + // The same gate the button draws itself from, re-read because the keyboard's Done + // reaches here without consulting it. + if (!state.canSubmit) return@onEach + + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_changeGroupName), + message = resources.getString( + R.string.prompt_description_changeGroupName, + ChatTitle.normalize(state.titleFieldState.text), + ), + actions = listOf( + BottomBarAction(resources.getString(R.string.action_changeGroupName)) { + viewModelScope.launch { + // The bar dismisses on an animation, and the submit's spinner + // belongs to the screen behind it — it would start underneath. + delay(150.milliseconds) + dispatchEvent(Event.SubmitTitle) + } + } + ), + showCancel = true, + ) + }.launchIn(viewModelScope) + eventFlow .filterIsInstance() .onEach { @@ -149,6 +190,7 @@ class EditGroupNameViewModel @Inject constructor( state.copy(chatId = event.chatId, savedTitle = event.title) } + Event.SaveClicked -> { state -> state } Event.SubmitTitle -> { state -> state } Event.OnTitleAccepted -> { state -> state } is Event.UpdateProcessingState -> { state -> diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureScreen.kt index 63931f645e..88e970df92 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureScreen.kt @@ -102,7 +102,7 @@ internal fun EditGroupPictureScreen(chatViewModel: ChatViewModel) { modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), topBar = { AppBarWithTitle( - title = stringResource(R.string.title_editGroupIcon), + title = stringResource(R.string.title_setGroupPhoto), titleAlignment = Alignment.CenterHorizontally, // The pick is dropped by leaving: the view model is scoped to this nav entry, so // popping it takes the draft with it and the stored picture stands. @@ -125,7 +125,7 @@ internal fun EditGroupPictureScreen(chatViewModel: ChatViewModel) { isLoading = state.processingState.loading, isSuccess = state.processingState.success, onClick = { - viewModel.dispatchEvent(EditGroupPictureViewModel.Event.SubmitPicture) + viewModel.dispatchEvent(EditGroupPictureViewModel.Event.SaveClicked) }, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt index 1be95af903..de4a2f6c23 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt @@ -16,6 +16,7 @@ import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.models.chat.RejectionReason import com.flipcash.services.models.blob.UploadPolicy import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ContentReader import com.getcode.util.resources.ResourceHelper @@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds @@ -54,7 +56,10 @@ class EditGroupPictureViewModel @Inject constructor( private val resources: ResourceHelper, ) : BaseViewModel( initialState = State(), - updateStateForEvent = updateStateForEvent + 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, @@ -86,6 +91,13 @@ class EditGroupPictureViewModel @Inject constructor( 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 picture is only proposed here: what uploads and sends it is + * [SubmitPicture], dispatched by the confirmation's own action. + */ + data object SaveClicked : Event + + /** The confirmed change. Reachable only through the prompt [SaveClicked] raises. */ data object SubmitPicture : Event data class UpdateProcessingState( val loading: Boolean = false, @@ -136,6 +148,28 @@ class EditGroupPictureViewModel @Inject constructor( .onEach { (cached, mime) -> dispatchEvent(Event.OnImageCached(cached, mime)) } .launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .onEach { + if (!stateFlow.value.canSubmit) return@onEach + + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_changeGroupPhoto), + message = resources.getString(R.string.prompt_description_changeGroupPhoto), + actions = listOf( + BottomBarAction(resources.getString(R.string.action_changeGroupPhoto)) { + 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.SubmitPicture) + } + } + ), + showCancel = true, + ) + }.launchIn(viewModelScope) + eventFlow .filterIsInstance() .onEach { submit() } @@ -265,6 +299,7 @@ class EditGroupPictureViewModel @Inject constructor( } is Event.UploadPolicyLoaded -> { state -> state.copy(uploadPolicy = event.policy) } + Event.SaveClicked -> { state -> state } Event.SubmitPicture -> { state -> state } Event.OnPictureAccepted -> { state -> state } is Event.UpdateProcessingState -> { 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 9876535875..54b096346d 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 @@ -4,6 +4,7 @@ import android.os.Parcelable import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.flipcash.app.core.chat.ChatStep @@ -38,6 +39,7 @@ internal fun EditGroupScreen() { topBar = { AppBarWithTitle( title = stringResource(R.string.title_editGroup), + titleAlignment = Alignment.CenterHorizontally, onBackIconClicked = { flowNavigator.back() }, ) }, 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 new file mode 100644 index 0000000000..7e6195baee --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupConfirmationTest.kt @@ -0,0 +1,180 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import android.net.Uri +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +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.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Save proposes; the prompt commits. + * + * A rename and a new picture are both visible to everyone in the group, so neither leaves the + * device on the strength of one tap. The gate lives in the view model rather than the screen + * precisely so these can assert the part that matters — that Save on its own sends nothing — in a + * plain unit test instead of an instrumented one. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class EditGroupConfirmationTest { + + private val chatCoordinator = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val chatId = ChatId(ByteArray(32) { 9 }.toList()) + + private val scheduler = TestCoroutineScheduler() + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher(scheduler)) + BottomBarManager.clear() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + BottomBarManager.clear() + } + + private fun nameModel() = EditGroupNameViewModel( + dispatchers = TestDispatchers(UnconfinedTestDispatcher(scheduler)), + chatCoordinator = chatCoordinator, + resources = resources, + ).apply { + dispatchEvent(EditGroupNameViewModel.Event.Initialize(chatId, title = "Dogs")) + } + + @Test + fun `saving a rename asks before it sends`() = runTest(scheduler) { + val model = nameModel() + advanceUntilIdle() + model.stateFlow.value.titleFieldState.setTextAndPlaceCursorAtEnd("Dog Park") + + model.dispatchEvent(EditGroupNameViewModel.Event.SaveClicked) + advanceUntilIdle() + + coVerify(exactly = 0) { chatCoordinator.editChat(any(), any()) } + assertEquals(1, BottomBarManager.messages.value.size) + } + + @Test + fun `confirming the rename is what sends it`() = runTest(scheduler) { + val model = nameModel() + advanceUntilIdle() + model.stateFlow.value.titleFieldState.setTextAndPlaceCursorAtEnd("Dog Park") + + // The success path reads the edited chat back off the result, so a relaxed stub's + // bare Object would fail there rather than at the assertion. + coEvery { chatCoordinator.editChat(any(), any()) } returns + Result.success(mockk(relaxed = true)) + + model.dispatchEvent(EditGroupNameViewModel.Event.SaveClicked) + advanceUntilIdle() + BottomBarManager.messages.value.last().actions.first().onClick() + advanceUntilIdle() + + coVerify(exactly = 1) { chatCoordinator.editChat(chatId, any()) } + } + + @Test + fun `dismissing the rename prompt keeps the draft and sends nothing`() = runTest(scheduler) { + val model = nameModel() + advanceUntilIdle() + model.stateFlow.value.titleFieldState.setTextAndPlaceCursorAtEnd("Dog Park") + + model.dispatchEvent(EditGroupNameViewModel.Event.SaveClicked) + advanceUntilIdle() + // What a cancel does: the prompt goes, nothing else is dispatched. + BottomBarManager.clear() + advanceUntilIdle() + + coVerify(exactly = 0) { chatCoordinator.editChat(any(), any()) } + assertEquals("Dog Park", model.stateFlow.value.titleFieldState.text.toString()) + } + + @Test + fun `an unchanged name raises no prompt`() = runTest(scheduler) { + // Save is disabled for it, but the keyboard's Done action reaches the same event. + val model = nameModel() + advanceUntilIdle() + + model.dispatchEvent(EditGroupNameViewModel.Event.SaveClicked) + advanceUntilIdle() + + assertTrue(BottomBarManager.messages.value.isEmpty()) + coVerify(exactly = 0) { chatCoordinator.editChat(any(), any()) } + } + + private fun pictureModel() = EditGroupPictureViewModel( + dispatchers = TestDispatchers(UnconfinedTestDispatcher(scheduler)), + chatCoordinator = chatCoordinator, + blobStorage = mockk(relaxed = true), + imagePreparer = mockk(relaxed = true), + contentReader = mockk(relaxed = true), + resources = resources, + ).apply { + dispatchEvent( + EditGroupPictureViewModel.Event.Initialize(chatId, picture = null, title = "Dogs") + ) + } + + @Test + fun `saving a picture asks before it uploads`() = runTest(scheduler) { + val model = pictureModel() + advanceUntilIdle() + model.dispatchEvent( + EditGroupPictureViewModel.Event.OnImageCached( + image = Uri.parse("file:///tmp/pick.jpg"), + mimeType = "image/jpeg", + ) + ) + advanceUntilIdle() + + model.dispatchEvent(EditGroupPictureViewModel.Event.SaveClicked) + advanceUntilIdle() + + coVerify(exactly = 0) { chatCoordinator.editChat(any(), any()) } + assertEquals(1, BottomBarManager.messages.value.size) + } + + @Test + fun `a picture with nothing picked raises no prompt`() = runTest(scheduler) { + val model = pictureModel() + advanceUntilIdle() + + model.dispatchEvent(EditGroupPictureViewModel.Event.SaveClicked) + advanceUntilIdle() + + assertTrue(BottomBarManager.messages.value.isEmpty()) + } +} + +private 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 +} From 68278421302e9b7c2974db3e9066503f24f8ff2b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 12:29:00 -0400 Subject: [PATCH 4/9] refactor(messenger): call the group's image a Picture, not a Photo It is what EditChatRequest.picture writes and what the rest of the app already calls it, so the design's "Icon" resolves to Picture rather than to a third word. Renames the row label, the screen title, the prompt and the menu item object together, so the string ids match their values. --- .../core/src/main/res/values/strings.xml | 20 +++++++++---------- .../profile/edit/EditGroupMenuItems.kt | 13 ++++++------ .../profile/edit/EditGroupPictureScreen.kt | 2 +- .../profile/edit/EditGroupPictureViewModel.kt | 6 +++--- 4 files changed, 20 insertions(+), 21 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index f5188bb5cc..f81623cc78 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1043,24 +1043,24 @@ calls the same thing a Group everywhere else (see title_newPublicGroup, hint_groupName), so it is a Group here too rather than introducing a second word for one concept. - The node lists four rows — Icon, Membership Card, Description, Social Links. Only Icon and - Name are here: the other three have no field on EditChatRequest in flipcash2 0.11.0. Name - is not in the node at all and takes the Icon row's styling. The node's Icon row ships as - Photo, which is what the row actually changes and what the rest of the app calls it. --> + The node lists four rows — Icon, Membership Card, Description, Social Links. Only the + first ships, as Picture: that is the field it writes (EditChatRequest.picture) and the + word the rest of the app uses. The other three have no field on EditChatRequest in + flipcash2 0.11.0. Name is not in the node at all and takes the first row's styling. --> Edit Group - Photo + Picture Name - Set Photo + Set Picture - Change Group Name? Everyone in this group will see it as \"%1$s\". Change Name - Change Group Photo? - Everyone in this group will see the new photo. - Change Photo + Change Group Picture? + Everyone in this group will see the new picture. + Change Picture Up to 64 characters 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 index 3f803db1cc..1211b51f54 100644 --- 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 @@ -20,16 +20,15 @@ internal sealed interface EditGroupAction { /** * The group's picture — node 10187:110373's first row, where the design calls it "Icon". * - * "Icon" is the row's own label in the design and is kept, even though the field it writes is - * `EditChatRequest.Picture` and the rest of the app calls it a picture: the label names what the - * user sees at the head of the group, not the wire field behind it. + * 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. */ -internal data object EditGroupIcon : FullMenuItem() { +internal data object EditGroupPicture : FullMenuItem() { override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Outlined.Image) override val name: String - @Composable get() = stringResource(R.string.title_editGroupPhoto) + @Composable get() = stringResource(R.string.title_editGroupPicture) override val action: EditGroupAction = EditGroupAction.Picture } @@ -39,7 +38,7 @@ internal data object EditGroupIcon : FullMenuItem() { * * 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 Icon row's styling rather than inventing its own. + * the row exists and takes the picture row's styling rather than inventing its own. */ internal data object EditGroupName : FullMenuItem() { override val icon: Painter @@ -60,4 +59,4 @@ internal data object EditGroupName : FullMenuItem() { * would be a control with nothing to write. */ internal fun editGroupItems(): List> = - listOf(EditGroupIcon, EditGroupName) + listOf(EditGroupPicture, EditGroupName) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureScreen.kt index 88e970df92..23cad3b039 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureScreen.kt @@ -102,7 +102,7 @@ internal fun EditGroupPictureScreen(chatViewModel: ChatViewModel) { modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), topBar = { AppBarWithTitle( - title = stringResource(R.string.title_setGroupPhoto), + title = stringResource(R.string.title_setGroupPicture), titleAlignment = Alignment.CenterHorizontally, // The pick is dropped by leaving: the view model is scoped to this nav entry, so // popping it takes the draft with it and the stored picture stands. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt index de4a2f6c23..baf200b340 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt @@ -154,10 +154,10 @@ class EditGroupPictureViewModel @Inject constructor( if (!stateFlow.value.canSubmit) return@onEach BottomBarManager.showAlert( - title = resources.getString(R.string.prompt_title_changeGroupPhoto), - message = resources.getString(R.string.prompt_description_changeGroupPhoto), + title = resources.getString(R.string.prompt_title_changeGroupPicture), + message = resources.getString(R.string.prompt_description_changeGroupPicture), actions = listOf( - BottomBarAction(resources.getString(R.string.action_changeGroupPhoto)) { + BottomBarAction(resources.getString(R.string.action_changeGroupPicture)) { viewModelScope.launch { // The bar dismisses on an animation, and the upload's spinner // belongs to the screen behind it — it would start underneath. From 64163696d39aed6c8444dfd0f7e9bf843988f317 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 12:35:23 -0400 Subject: [PATCH 5/9] fix(messenger): draw the edit confirmations neutral, not destructive showAlert paints the bar red, which reads as a warning about something being taken away. Both edits it guards are additive and reversible; what justifies asking at all is that the new name or picture goes to everyone in the group, and the prompts already say so. NameEntryViewModel and PhotoSelectionViewModel confirm the same two edits on the user's own profile with showMessage and keep showAlert for the failures. These now match on both counts. --- .../internal/screens/profile/edit/EditGroupNameViewModel.kt | 2 +- .../internal/screens/profile/edit/EditGroupPictureViewModel.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt index eba7686f8f..db65670bf3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt @@ -105,7 +105,7 @@ class EditGroupNameViewModel @Inject constructor( // reaches here without consulting it. if (!state.canSubmit) return@onEach - BottomBarManager.showAlert( + BottomBarManager.showMessage( title = resources.getString(R.string.prompt_title_changeGroupName), message = resources.getString( R.string.prompt_description_changeGroupName, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt index baf200b340..778e4444cd 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt @@ -153,7 +153,7 @@ class EditGroupPictureViewModel @Inject constructor( .onEach { if (!stateFlow.value.canSubmit) return@onEach - BottomBarManager.showAlert( + BottomBarManager.showMessage( title = resources.getString(R.string.prompt_title_changeGroupPicture), message = resources.getString(R.string.prompt_description_changeGroupPicture), actions = listOf( From edcc41393cd796f69394787deb6540ab912c8916 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 12:38:36 -0400 Subject: [PATCH 6/9] fix(messenger): confirm group edits on the alert style, not the neutral one The account screens confirm these same two edits with showMessage, and matching them is what 64163696d changed. But those edits are the user's own profile. A group's name and picture change for everyone in the chat, and showAlert is the weight that carries. Reverts 64163696d. --- .../internal/screens/profile/edit/EditGroupNameViewModel.kt | 2 +- .../internal/screens/profile/edit/EditGroupPictureViewModel.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt index db65670bf3..eba7686f8f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt @@ -105,7 +105,7 @@ class EditGroupNameViewModel @Inject constructor( // reaches here without consulting it. if (!state.canSubmit) return@onEach - BottomBarManager.showMessage( + BottomBarManager.showAlert( title = resources.getString(R.string.prompt_title_changeGroupName), message = resources.getString( R.string.prompt_description_changeGroupName, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt index 778e4444cd..baf200b340 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureViewModel.kt @@ -153,7 +153,7 @@ class EditGroupPictureViewModel @Inject constructor( .onEach { if (!stateFlow.value.canSubmit) return@onEach - BottomBarManager.showMessage( + BottomBarManager.showAlert( title = resources.getString(R.string.prompt_title_changeGroupPicture), message = resources.getString(R.string.prompt_description_changeGroupPicture), actions = listOf( From 6f5e30314b083c47e0ec83182334e8c9c43ff5a6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 12:47:20 -0400 Subject: [PATCH 7/9] feat(messenger): head the group name edit with Set Name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen had no title at all, so it opened on a bare field with nothing saying what the field was for. It now carries an inline heading the way NameEntryScreen does: no app bar title, a textLarge Text at 70% width above the field. That asymmetry with the picture screen, which keeps its app bar title, is the account flow's own split rather than an inconsistency — PhotoSelectionScreen titles its bar and NameEntryScreen heads its content. --- .claude/state/handoff.md | 27 ++++ .../core/src/main/res/values/strings.xml | 6 +- .../profile/edit/EditGroupNameScreen.kt | 123 ++++++++++-------- 3 files changed, 99 insertions(+), 57 deletions(-) create mode 100644 .claude/state/handoff.md diff --git a/.claude/state/handoff.md b/.claude/state/handoff.md new file mode 100644 index 0000000000..b32687f62f --- /dev/null +++ b/.claude/state/handoff.md @@ -0,0 +1,27 @@ +# Handoff — group-chat Edit UI + +**Done and shipped.** [code-android-app#1525](https://github.com/code-payments/code-android-app/pull/1525), +stacked on #1524 (`feat/chat-roster-edit-rpcs`). Worktree `.claude/worktrees/edit-chat-ui`, +branch `feat/edit-chat-ui`, commit `e0479e2e9`. + +## Verified green +`:services:flipcash:compileDebugKotlin`, `:apps:flipcash:core:`, `:apps:flipcash:shared:chat:`, +messenger + tipping `testDebugUnitTest`, and `:apps:flipcash:app:assembleDebug` (the last one +proves the two new `@HiltViewModel`s wire up). 12 new tests, all ran. + +## Key decisions +- `canEdit` gate is passed in, never derived. `groupProfileOverflowItems(canEdit)` returns a list + so no-permission means no overflow button. +- `titleOnly` / `pictureOnly` in `PartialEdits.kt` encode "send only what changed" — EditChat + leaves unset fields alone, so an over-filled request is destructive. +- Title length counted in **code points** (protovalidate max_len semantics), on the trimmed value. +- Echo is idempotent: `TitleChanged`/`PictureChanged` carry absolute values, so persisting the + editChat response and then receiving the stream event writes the same thing. +- `moderationDescription` extracted from `CreateGroupViewModel` to `core/moderation`, unchanged. +- Built Icon + Name only; the other three node rows have no flipcash2 0.11.0 contract. +- Used repo `MenuList`/`ListItem` metrics (24dp/17sp), not the node's 26dp/20sp — flagged in the PR. +- Terminology: "Group", not the node's "Community". + +## Environment gotcha +`google-services.json` is gitignored and absent from fresh worktrees; copy it from the main +checkout (`apps/flipcash/app/`) before any `assembleDebug`. diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index f81623cc78..bb1b1094c9 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1050,8 +1050,12 @@ Edit Group Picture Name - + Set Picture + Set Name diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameScreen.kt index 964ac51a41..9e01b8b726 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameScreen.kt @@ -7,6 +7,7 @@ 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 @@ -73,62 +74,72 @@ internal fun EditGroupNameScreen(chatViewModel: ChatViewModel) { .launchIn(this) } - CodeScaffold( - topBar = { - // No title. The field is the screen, and the row that opened it already said Name; - // the bar is here for the back control. - AppBarWithTitle( - onBackIconClicked = { keyboard.hideIfVisible { flowNavigator.back() } }, - ) - }, - bottomBar = { - CodeButton( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset) - .navigationBarsPadding() - .padding( - top = CodeTheme.dimens.grid.x6, - bottom = CodeTheme.dimens.grid.x3, - ).imePadding(), - text = stringResource(R.string.action_save), - enabled = state.canSubmit, - isLoading = state.processingState.loading, - isSuccess = state.processingState.success, - onClick = { - keyboard.hideIfVisible { - viewModel.dispatchEvent(EditGroupNameViewModel.Event.SaveClicked) - } - }, - ) - }, - ) { padding -> - val focusRequester = remember { FocusRequester() } - Column( - modifier = Modifier - .padding(padding) - .padding(horizontal = CodeTheme.dimens.inset) - ) { - DisplayTextInput( - state = state.titleFieldState, - placeholder = stringResource(R.string.hint_groupName), - sublabel = stringResource(R.string.subtitle_groupNameLength), - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Words, - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Done, - ), - onKeyboardAction = { - keyboard.hideIfVisible { - viewModel.dispatchEvent(EditGroupNameViewModel.Event.SaveClicked) - } - }, - ) - } + // Laid out as NameEntryScreen lays out the same edit: the bar carries the back control and no + // title, and the heading sits inside the scaffold above the field, so it scrolls and insets + // with the content rather than floating in a bar over it. + Column { + AppBarWithTitle( + onBackIconClicked = { keyboard.hideIfVisible { flowNavigator.back() } }, + ) + CodeScaffold( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + topBar = { + Text( + modifier = Modifier + .fillMaxWidth(0.7f) + .padding( + top = CodeTheme.dimens.grid.x2, + bottom = CodeTheme.dimens.inset, + ), + text = stringResource(R.string.title_setGroupName), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + }, + bottomBar = { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding( + top = CodeTheme.dimens.grid.x6, + bottom = CodeTheme.dimens.grid.x3, + ).imePadding(), + text = stringResource(R.string.action_save), + enabled = state.canSubmit, + isLoading = state.processingState.loading, + isSuccess = state.processingState.success, + onClick = { + keyboard.hideIfVisible { + viewModel.dispatchEvent(EditGroupNameViewModel.Event.SaveClicked) + } + }, + ) + }, + ) { padding -> + val focusRequester = remember { FocusRequester() } + Column(modifier = Modifier.padding(padding)) { + DisplayTextInput( + state = state.titleFieldState, + placeholder = stringResource(R.string.hint_groupName), + sublabel = stringResource(R.string.subtitle_groupNameLength), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + ), + onKeyboardAction = { + keyboard.hideIfVisible { + viewModel.dispatchEvent(EditGroupNameViewModel.Event.SaveClicked) + } + }, + ) + } - LaunchedEffect(Unit) { focusRequester.requestFocus() } + LaunchedEffect(Unit) { focusRequester.requestFocus() } + } } } From 3cfb587f759dd68c1d691906a7be877310e9cbb2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 12:54:45 -0400 Subject: [PATCH 8/9] chore(messenger): drop the trailing periods from the edit confirmations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most of the bottom bar's prompt descriptions end without one — 16 of the 20 prompt_description_* strings — so these two were the outliers. --- apps/flipcash/core/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index bb1b1094c9..52a0a97b6d 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1060,10 +1060,10 @@ Change Group Name? - Everyone in this group will see it as \"%1$s\". + Everyone in this group will see it as \"%1$s\" Change Name Change Group Picture? - Everyone in this group will see the new picture. + Everyone in this group will see the new picture Change Picture Up to 64 characters From 13957bda6531f9c73f903fb486560df17f471e26 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 12:54:54 -0400 Subject: [PATCH 9/9] fix(messenger): line up the picture screen's back button with every other one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bar was nested in the CodeScaffold that carries padding(horizontal = inset), so the back control started at inset + grid.x2 — 20dp further in than the same control on the name screen beside it. Hoisting the bar into a Column above the scaffold is how PhotoSelectionScreen, NameEntryScreen and EditGroupNameScreen all place theirs, and it leaves the centred title centred against the full screen width rather than the inset body. --- .../profile/edit/EditGroupPictureScreen.kt | 164 +++++++++--------- 1 file changed, 84 insertions(+), 80 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureScreen.kt index 23cad3b039..425390ed35 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupPictureScreen.kt @@ -98,95 +98,99 @@ internal fun EditGroupPictureScreen(chatViewModel: ChatViewModel) { } } - CodeScaffold( - modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), - topBar = { - AppBarWithTitle( - title = stringResource(R.string.title_setGroupPicture), - titleAlignment = Alignment.CenterHorizontally, - // The pick is dropped by leaving: the view model is scoped to this nav entry, so - // popping it takes the draft with it and the stored picture stands. - onBackIconClicked = { flowNavigator.back() }, - ) - }, - bottomBar = { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset), - ) { - 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(EditGroupPictureViewModel.Event.SaveClicked) - }, - ) - } - }, - ) { padding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding), - contentAlignment = Alignment.Center, - ) { + Column { + AppBarWithTitle( + title = stringResource(R.string.title_setGroupPicture), + titleAlignment = Alignment.CenterHorizontally, + // The pick is dropped by leaving: the view model is scoped to this nav entry, so + // popping it takes the draft with it and the stored picture stands. + onBackIconClicked = { flowNavigator.back() }, + ) + CodeScaffold( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + // The bar sits outside the scaffold's horizontal inset, as PhotoSelectionScreen and + // EditGroupNameScreen place theirs. Nesting it inside indents the back control by + // [inset], putting it out of line with the back control on every other screen. + topBar = {}, + bottomBar = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset), + ) { + 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(EditGroupPictureViewModel.Event.SaveClicked) + }, + ) + } + }, + ) { padding -> Box( modifier = Modifier - .size(150.dp) - .background(color = CodeTheme.colors.divider, shape = CircleShape) - .clip(CircleShape) - .clickable { - pickMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) - }, + .fillMaxSize() + .padding(padding), 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() + Box( + modifier = Modifier + .size(150.dp) + .background(color = CodeTheme.colors.divider, shape = CircleShape) + .clip(CircleShape) + .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(), + ) } - } - // No pick pending: the group's stored picture, so the screen opens on the - // current icon rather than an empty well. Save stays disabled until a pick - // is made, so this cannot be mistaken for one. - state.savedPicture != null -> { - ContactAvatar( - image = state.savedPicture, - displayName = state.groupTitle, - access = BlobAccessContext.Owned, - modifier = Modifier.fillMaxSize(), - ) - } + pending.isLoading() && pending.dataOrNull != null -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CodeCircularProgressIndicator() + } + } - else -> { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null, - tint = White50, - modifier = Modifier.size(48.dp), + // No pick pending: the group's stored picture, so the screen opens on the + // current icon rather than an empty well. Save stays disabled until a pick + // is made, so this cannot be mistaken for one. + state.savedPicture != null -> { + ContactAvatar( + image = state.savedPicture, + displayName = state.groupTitle, + access = BlobAccessContext.Owned, + modifier = Modifier.fillMaxSize(), ) } + + else -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + tint = White50, + modifier = Modifier.size(48.dp), + ) + } + } } } }