From 64b68c6c783550a552fa59cf170722205e58046b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 7 Oct 2026 11:40:32 -0400 Subject: [PATCH] fix(groups): re-land the Edit Group refresh from #1705 #1705 merged into feat/group-profile-refresh a minute after #1703 had already merged that branch into code/cash, so the Edit Group screens with cover and description editors never reached code/cash. This is #1705's squash commit cherry-picked onto code/cash. Two conflicts came from work that landed since: - BalanceRequirementsCard keeps #1721's Community Currency row alongside #1705's compact mode and footnote. - holdingLabel moved to GroupHoldingLabel.kt in #1705; it keeps the namesToken parameter code/cash added to the copy in GroupProfileScreen. --- .../com/flipcash/app/core/chat/ChatStep.kt | 15 +- .../core/src/main/res/values/strings.xml | 66 ++-- .../flipcash/app/messenger/ChatFlowScreen.kt | 29 +- .../screens/profile/GroupHoldingLabel.kt | 34 +++ .../screens/profile/GroupProfileScreen.kt | 24 -- .../screens/profile/edit/ChatTitle.kt | 7 + .../profile/edit/EditGroupCoverScreen.kt | 191 ++++++++++++ .../profile/edit/EditGroupCoverViewModel.kt | 288 ++++++++++++++++++ .../edit/EditGroupDescriptionScreen.kt | 150 +++++++++ .../edit/EditGroupDescriptionViewModel.kt | 150 +++++++++ .../profile/edit/EditGroupMenuItems.kt | 65 ---- .../profile/edit/EditGroupNameScreen.kt | 24 +- .../profile/edit/EditGroupNameViewModel.kt | 55 +--- .../profile/edit/EditGroupPictureViewModel.kt | 49 +-- .../screens/profile/edit/EditGroupScreen.kt | 109 +++++-- .../screens/profile/edit/GroupChange.kt | 113 +++++++ .../profile/edit/GroupDescriptionDraft.kt | 29 ++ .../screens/profile/edit/PartialEdits.kt | 23 +- .../edit/EditGroupCoverViewModelTest.kt | 149 +++++++++ .../edit/EditGroupDescriptionViewModelTest.kt | 185 +++++++++++ .../profile/edit/GroupChangeDialogTest.kt | 76 +++++ .../screens/profile/edit/PartialEditsTest.kt | 38 +++ .../editprofile/EditProfileScreenContent.kt | 177 +---------- .../shared/common-ui/build.gradle.kts | 1 + .../ui/profile/BalanceRequirementsCard.kt | 25 +- .../common/ui/profile/ProfileEditFields.kt | 203 ++++++++++++ 26 files changed, 1865 insertions(+), 410 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupHoldingLabel.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupCoverScreen.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupCoverViewModel.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupDescriptionScreen.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupDescriptionViewModel.kt delete 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/GroupChange.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/GroupDescriptionDraft.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupCoverViewModelTest.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupDescriptionViewModelTest.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/GroupChangeDialogTest.kt create mode 100644 apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileEditFields.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 458dc3a238..694031433e 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -94,9 +94,8 @@ sealed interface ChatStep : FlowStep, Parcelable { * the flow is already open on the group, and every step here reads it off the conversation's * view model. * - * The design node lists four rows; only [EditGroupPicture] and [EditGroupName] are built. - * Membership card, description and social links have no field on `EditChatRequest` in - * flipcash2 0.11.0, so there is nothing for them to write. + * Name, description, picture and cover each open their own editor and save on their own; + * the balance requirements are shown read-only, since nothing on `EditChatRequest` writes them. */ @Parcelize @Serializable @@ -112,6 +111,16 @@ sealed interface ChatStep : FlowStep, Parcelable { @Serializable data object EditGroupPicture : ChatStep + /** The group's banner — the same pick, upload and `EditChat` as the picture, for the cover. */ + @Parcelize + @Serializable + data object EditGroupCover : ChatStep + + /** The group's description, up to 160 characters; emptying it clears it. */ + @Parcelize + @Serializable + data object EditGroupDescription : ChatStep + /** * The full emoji picker for a message's reaction, opened from the pill row's "+" or the * quick strip's "+". A plain [Sheet] rather than [WrapContentSheet]: the picker's catalog diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index f8acc4d035..e6b013469a 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1138,39 +1138,53 @@ Something Went Wrong We couldn\'t create this group. Please try again. - + Edit Group - Picture - Name - - Set Picture + Group name + Add a name + Description + Add a description + People keep their balance. These amounts determine who can join and send messages. + + Picture + Description What\'s the group name? - - + Group Name + Add a description + %1$d characters + This description isn\'t allowed. Try different wording + + Change Group Name? This will change the group name for everyone in it - Change Name + Change Group Name Change Group Picture? This will change the group picture for everyone in it - Change Picture - - Can\'t Edit This Group - You don\'t have permission to edit this group + Change Group Picture + Change Group Cover? + This will change the group cover for everyone in it + Change Group Cover + Change Group Description? + This will change the group description for everyone in it + Change Group Description + + You Can\'t Edit This Group + Try again later + This Group No Longer Exists + It may have been deleted + This Name is Not Allowed + Try a different group name + This Photo Isn\'t Allowed + Try a different photo - Something Went Wrong - We couldn\'t save your changes. Please try again. + Couldn\'t Save This Name + Couldn\'t Save This Photo + Couldn\'t Save the Description + Try again Invite to Join Group diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 4c53fa8b67..bb5a6a679e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -36,6 +36,8 @@ import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent import com.flipcash.app.messenger.internal.screens.profile.ChatProfileScreen import com.flipcash.app.messenger.internal.screens.profile.ChatProfileViewModel import com.flipcash.app.messenger.internal.screens.profile.GroupProfileScreen +import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupCoverScreen +import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupDescriptionScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupNameScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupPictureScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupScreen @@ -115,7 +117,7 @@ private fun chatEntryProvider( FlowGroupProfileScreen() } annotatedEntry { - EditGroupScreen() + FlowEditGroupScreen() } annotatedEntry { FlowEditGroupNameScreen() @@ -123,6 +125,12 @@ private fun chatEntryProvider( annotatedEntry { FlowEditGroupPictureScreen() } + annotatedEntry { + FlowEditGroupCoverScreen() + } + annotatedEntry { + FlowEditGroupDescriptionScreen() + } annotatedEntry { step -> FlowReactionPickerScreen(step.messageId) @@ -312,9 +320,24 @@ private fun FlowGroupProfileScreen() { GroupProfileScreen(flowSharedViewModel()) } -// Both edit steps read the chat they are editing off the flow's shared ChatViewModel, the same way +// The edit steps read the chat they are editing off the flow's shared ChatViewModel, the same way // the profile above them does, and keep the edit itself in their own nav-entry-scoped view model. -// EditGroupScreen needs neither and is registered directly. +// The list itself holds no edit, but reads the group and its permissions the same way. +@Composable +private fun FlowEditGroupScreen() { + EditGroupScreen(flowSharedViewModel()) +} + +@Composable +private fun FlowEditGroupCoverScreen() { + EditGroupCoverScreen(flowSharedViewModel()) +} + +@Composable +private fun FlowEditGroupDescriptionScreen() { + EditGroupDescriptionScreen(flowSharedViewModel()) +} + @Composable private fun FlowEditGroupNameScreen() { EditGroupNameScreen(flowSharedViewModel()) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupHoldingLabel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupHoldingLabel.kt new file mode 100644 index 0000000000..1aaba48cd3 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupHoldingLabel.kt @@ -0,0 +1,34 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.flipcash.app.messenger.internal.RuleCurrency +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint + +/** + * A rule's amount as the profile writes it: "$10", "$2.50", and "$10 of NYC" for a token other + * than USDF whose name is known. A rule naming no mint, or the reserve, states the amount alone, + * as does any rule when [namesToken] is false because the card already names the token. + */ +@Composable +internal fun holdingLabel( + rule: ChatRuleRequirement.MinimumBalance, + tokens: Map, RuleCurrency>, + namesToken: Boolean = true, +): String = holdingLabel(rule.amount, rule.mints.firstOrNull()?.takeIf { namesToken }?.let { Mint(it.bytes) }, tokens) + +@Composable +internal fun holdingLabel( + amount: Fiat, + mint: Mint?, + tokens: Map, RuleCurrency>, +): String { + val formatted = amount.formatted(Fiat.FormattingRule.Truncated) + val name = mint?.takeUnless { it == Mint.usdf }?.let { tokens[it.bytes] }?.name + ?: return formatted + return stringResource(R.string.label_chat_preview_cash_suffix, formatted, name) +} + diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt index 86dc2278e8..54251d24dd 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt @@ -394,30 +394,6 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { } } -/** - * A rule's amount as the profile writes it: "$10", "$2.50", and "$10 of NYC" for a token other - * than USDF whose name is known. A rule naming no mint, or the reserve, states the amount alone, - * as does any rule when [namesToken] is false because the card already names the token. - */ -@Composable -private fun holdingLabel( - rule: ChatRuleRequirement.MinimumBalance, - tokens: Map, com.flipcash.app.messenger.internal.RuleCurrency>, - namesToken: Boolean = true, -): String = holdingLabel(rule.amount, rule.mints.firstOrNull()?.takeIf { namesToken }?.let { Mint(it.bytes) }, tokens) - -@Composable -private fun holdingLabel( - amount: Fiat, - mint: Mint?, - tokens: Map, com.flipcash.app.messenger.internal.RuleCurrency>, -): String { - val formatted = amount.formatted(Fiat.FormattingRule.Truncated) - val name = mint?.takeUnless { it == Mint.usdf }?.let { tokens[it.bytes] }?.name - ?: return formatted - return stringResource(R.string.label_chat_preview_cash_suffix, formatted, name) -} - /** The ⋯ button's menu: Encryption and Mute, then a divider and the red Report. */ @Composable private fun GroupProfileMenu( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitle.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitle.kt index 3128415c47..6f4c7ede49 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitle.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitle.kt @@ -22,5 +22,12 @@ internal object ChatTitle { fun lengthOf(input: CharSequence): Int = normalize(input).let { it.codePointCount(0, it.length) } + /** How many more code points the field takes, counted on the text as typed; never negative. */ + fun remaining(input: CharSequence): Int = + (MAX_LENGTH - input.toString().let { it.codePointCount(0, it.length) }).coerceAtLeast(0) + + /** The counter only appears once the end is in sight. */ + const val COUNTER_THRESHOLD = 10 + fun isValid(input: CharSequence): Boolean = lengthOf(input) in MIN_LENGTH..MAX_LENGTH } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupCoverScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupCoverScreen.kt new file mode 100644 index 0000000000..a2f60b9c50 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupCoverScreen.kt @@ -0,0 +1,191 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import android.os.Parcelable +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import com.flipcash.app.core.chat.ChatStep +import com.flipcash.shared.common.ui.profile.ProfileCover +import com.flipcash.app.core.data.isLoaded +import com.flipcash.app.core.data.isLoading +import com.flipcash.app.messenger.internal.ChatSubject +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.theme.White50 +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeCircularProgressIndicator +import com.getcode.ui.theme.CodeScaffold +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * The group's cover. The banner is the picker: the stored cover shows in it until a pick replaces + * it, and Save stays off until one is made. Drawn at the height it has on Edit Group and clipped the + * same way, so what is picked is what the profile will show. + */ +@Composable +internal fun EditGroupCoverScreen(chatViewModel: ChatViewModel) { + val flowNavigator = rememberFlowNavigator() + val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle() + val group = chatState.subject as? ChatSubject.Group + + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + LaunchedEffect(group?.chatId) { + val resolved = group ?: return@LaunchedEffect + viewModel.dispatchEvent( + EditGroupCoverViewModel.Event.Initialize( + chatId = resolved.chatId, + cover = resolved.coverPicture, + ) + ) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.back() } + .launchIn(this) + } + + val pickMedia = rememberLauncherForActivityResult(PickVisualMedia()) { uri -> + if (uri != null) { + trace(tag = TAG, message = "image selected @ $uri", type = TraceType.User) + viewModel.dispatchEvent(EditGroupCoverViewModel.Event.OnImageSelected(uri)) + } else { + trace(tag = TAG, message = "No image selected", type = TraceType.User) + } + } + + Column { + AppBarWithTitle( + title = stringResource(R.string.title_cover), + titleAlignment = Alignment.CenterHorizontally, + // The pick is dropped by leaving: the view model is scoped to this nav entry. + onBackIconClicked = { flowNavigator.back() }, + ) + CodeScaffold( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + topBar = {}, + bottomBar = { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(bottom = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.action_save), + enabled = state.canSubmit, + isLoading = state.processingState.loading, + isSuccess = state.processingState.success, + onClick = { + viewModel.dispatchEvent(EditGroupCoverViewModel.Event.SaveClicked) + }, + ) + }, + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(top = CodeTheme.dimens.staticGrid.x4), + contentAlignment = Alignment.TopCenter, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(CoverBannerHeight) + .clip(CodeTheme.shapes.medium) + .background(CodeTheme.colors.surfaceVariant) + .clickable { + pickMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) + }, + contentAlignment = Alignment.Center, + ) { + Crossfade(targetState = state.image) { pending -> + when { + pending.isLoaded() -> { + AsyncImage( + model = ImageRequest.Builder(LocalPlatformContext.current) + .data(pending.data) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + + pending.isLoading() && pending.dataOrNull != null -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CodeCircularProgressIndicator() + } + } + + // No pick pending: the stored cover, so the screen opens on what the + // group has. Save stays disabled until a pick is made. + state.savedCover != null -> { + ProfileCover( + image = state.savedCover, + access = BlobAccessContext.Owned, + modifier = Modifier.fillMaxSize(), + height = Dp.Unspecified, + ) + } + + else -> { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + modifier = Modifier.size(40.dp), + ) + } + } + } + } + } + } + } +} + +private val CoverBannerHeight = 214.dp + +private const val TAG = "EditGroupCover" diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupCoverViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupCoverViewModel.kt new file mode 100644 index 0000000000..3013414461 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupCoverViewModel.kt @@ -0,0 +1,288 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import android.net.Uri +import androidx.lifecycle.viewModelScope +import com.flipcash.app.blob.BlobStorageCoordinator +import com.flipcash.app.blob.ImageUploadPreparer +import com.flipcash.app.core.data.Loadable +import com.flipcash.app.core.moderation.moderationDescription +import com.flipcash.features.messenger.R +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.models.BlobRejectedException +import com.flipcash.services.models.EditChatError +import com.flipcash.services.models.blob.UploadPolicy +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.chat.RejectionReason +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ContentReader +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import com.getcode.view.LoadingSuccessState +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +/** + * The group-cover edit: the banner on Edit Group is the picker. + * + * The pick-prepare-upload path is `CreateGroupViewModel`'s, because group creation already + * collects a group picture and the two have to produce the same thing: an ORIGINAL rendition + * inside the server's current [UploadPolicy], uploaded through [BlobStorageCoordinator], whose + * `upload` returns only once the blob is READY. Nothing new uploads here. + * + * That READY guarantee is what makes the ordering correct: `EditChat` answers + * `PICTURE_BLOB_NOT_ACCEPTED` for a blob still PENDING or PROCESSING, and the only way to reach + * the edit call below is through a successful `upload`. + */ +@HiltViewModel +class EditGroupCoverViewModel @Inject constructor( + dispatchers: DispatcherProvider, + private val chatCoordinator: ChatCoordinator, + private val blobStorage: BlobStorageCoordinator, + private val imagePreparer: ImageUploadPreparer, + private val contentReader: ContentReader, + private val resources: ResourceHelper, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, + // The base hops to Dispatchers.Default to publish events. Taking it from the injected + // provider instead keeps every dispatcher this class touches on one seam. + defaultDispatcher = dispatchers.Default, +) { + data class State( + val chatId: ChatId? = null, + /** The group's stored cover — what the banner shows until a pick replaces it. */ + val savedCover: MediaItem? = null, + /** The pending pick, re-encoded and cached on disk. Null until one is made. */ + val image: Loadable = Loadable.Loading(), + val mimeType: String? = null, + val uploadPolicy: UploadPolicy? = null, + val processingState: LoadingSuccessState = LoadingSuccessState(), + ) { + /** Only a fresh pick can be saved; the stored cover is not a change to itself. */ + val isChanged: Boolean + get() = image.dataOrNull != null + + val canSubmit: Boolean + get() = isChanged && processingState.isIdle && chatId != null + } + + sealed interface Event { + data class Initialize( + val chatId: ChatId, + val cover: MediaItem?, + ) : Event + + data class OnImageSelected(val image: Uri) : Event + data class OnImageCached(val image: Uri, val mimeType: String) : Event + data object DiscardPendingImage : Event + data class UploadPolicyLoaded(val policy: UploadPolicy) : Event + /** + * Save, pressed. The new cover is only proposed here: what uploads and sends it is + * [SubmitCover], dispatched by the confirmation's own action. + */ + data object SaveClicked : Event + + /** The confirmed change. Reachable only through the prompt [SaveClicked] raises. */ + data object SubmitCover : Event + data class UpdateProcessingState( + val loading: Boolean = false, + val success: Boolean = false, + ) : Event + + data object OnCoverAccepted : Event + } + + init { + blobStorage.policy + .filterNotNull() + .onEach { dispatchEvent(Event.UploadPolicyLoaded(it)) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { event -> + // Re-encode inside the policy's dimension and byte caps, off the main thread — + // the same call group creation makes, so both produce blobs the server will take. + when (val outcome = imagePreparer.prepare( + uri = event.image, + policy = stateFlow.value.uploadPolicy, + fileNamePrefix = "group_cover", + )) { + is ImageUploadPreparer.Outcome.Prepared -> outcome.uri to outcome.mimeType + ImageUploadPreparer.Outcome.Unsupported -> { + rejectImage( + title = R.string.error_title_imageNotSupported, + message = R.string.error_description_imageNotSupported, + ) + null + } + + ImageUploadPreparer.Outcome.TooLarge -> { + rejectImage( + title = R.string.error_title_imageTooLarge, + message = R.string.error_description_imageTooLarge, + ) + null + } + + // The preparer has already said why in the log; there is nothing to add. + ImageUploadPreparer.Outcome.Unreadable -> null + } + } + .flowOn(dispatchers.IO) + .onEach { (cached, mime) -> dispatchEvent(Event.OnImageCached(cached, mime)) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + if (!stateFlow.value.canSubmit) return@onEach + + showGroupChangeConfirmation(resources, GroupChangeField.Cover) { + viewModelScope.launch { + // The bar dismisses on an animation, and the upload's spinner + // belongs to the screen behind it — it would start underneath. + delay(150.milliseconds) + dispatchEvent(Event.SubmitCover) + } + } + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { submit() } + .launchIn(viewModelScope) + } + + private suspend fun submit() { + val state = stateFlow.value + val chatId = state.chatId ?: return + val uri = state.image.dataOrNull ?: return + val mimeType = state.mimeType ?: return + + dispatchEvent(Event.UpdateProcessingState(loading = true)) + + val blobId = uploadPicture(uri, mimeType) + if (blobId == null) { + dispatchEvent(Event.UpdateProcessingState()) + return + } + + // Cover only: every other field stays unset so the server leaves it alone. + chatCoordinator.editChat( + chatId = chatId, + parameters = coverOnly(blobId), + ).onSuccess { + dispatchEvent(Event.UpdateProcessingState(success = true)) + delay(500.milliseconds) + dispatchEvent(Event.OnCoverAccepted) + dispatchEvent(Event.UpdateProcessingState()) + }.onFailure { cause -> + dispatchEvent(Event.UpdateProcessingState()) + announceEditFailure(cause) + } + } + + /** Uploads the prepared picture and returns its READY blob, or null having reported why not. */ + private suspend fun uploadPicture(uri: Uri, mimeType: String): BlobId? { + val bytes = contentReader.readBytes(uri) + if (bytes == null) { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_moderationFailed), + message = resources.getString(R.string.error_description_moderationFailed), + ) + return null + } + + return blobStorage.upload(bytes = bytes, mimeType = mimeType) + .onFailure { cause -> + dispatchEvent(Event.DiscardPendingImage) + announcePictureRejection(cause) + } + .getOrNull() + } + + private fun announcePictureRejection(cause: Throwable) { + val rejection = (cause as? BlobRejectedException)?.rejection + when (rejection?.reason) { + RejectionReason.MODERATION -> BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_imageNotAllowed), + message = resources.getString(moderationDescription(rejection.flaggedCategory)), + ) + + else -> BottomBarManager.showError( + title = resources.getString(R.string.error_title_moderationFailed), + message = resources.getString(R.string.error_description_moderationFailed), + ) + } + } + + private fun announceEditFailure(cause: Throwable) { + // Storage took the blob and EditChat would not have it, so the pick is what has to + // change — it is dropped rather than left on screen inviting the same upload again. + if (cause is EditChatError.CoverPictureBlobNotAccepted) dispatchEvent(Event.DiscardPendingImage) + showGroupEditAlert(resources, groupEditAlert(cause, GroupChangeField.Cover)) + } + + private fun rejectImage(title: Int, message: Int) { + BottomBarManager.showAlert( + title = resources.getString(title), + message = resources.getString(message), + ) + } + + internal companion object { + val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + is Event.Initialize -> { state -> + state.copy( + chatId = event.chatId, + savedCover = event.cover, + ) + } + + is Event.OnImageSelected -> { state -> + // Held as Loading with the source uri so the well can show the pick while it + // is being re-encoded, instead of going blank between tap and preview. + state.copy(image = Loadable.Loading(event.image)) + } + + is Event.OnImageCached -> { state -> + state.copy( + image = Loadable.Loaded(event.image), + mimeType = event.mimeType, + ) + } + + Event.DiscardPendingImage -> { state -> + state.copy(image = Loadable.Loading(), mimeType = null) + } + + is Event.UploadPolicyLoaded -> { state -> state.copy(uploadPolicy = event.policy) } + Event.SaveClicked -> { state -> state } + Event.SubmitCover -> { state -> state } + Event.OnCoverAccepted -> { state -> state } + is Event.UpdateProcessingState -> { state -> + state.copy( + processingState = state.processingState.copy( + loading = event.loading, + success = event.success, + ) + ) + } + } + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupDescriptionScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupDescriptionScreen.kt new file mode 100644 index 0000000000..dbbe26dd28 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupDescriptionScreen.kt @@ -0,0 +1,150 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import android.os.Parcelable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.ui.DisplayTextInput +import com.flipcash.app.core.ui.transitions.RequestFocusWhenSettled +import com.flipcash.app.messenger.internal.ChatSubject +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.features.messenger.R +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.utils.rememberKeyboardController +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * The group's description: a multiline field of three to eight lines, the characters left under it + * (red once it is over), and a moderated description's refusal under that. + */ +@Composable +internal fun EditGroupDescriptionScreen(chatViewModel: ChatViewModel) { + val flowNavigator = rememberFlowNavigator() + val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle() + val group = chatState.subject as? ChatSubject.Group + + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val keyboard = rememberKeyboardController() + + // Keyed on the chat so the seed waits for the group and never runs over an edit in progress. + LaunchedEffect(group?.chatId) { + val resolved = group ?: return@LaunchedEffect + viewModel.dispatchEvent( + EditGroupDescriptionViewModel.Event.Initialize( + chatId = resolved.chatId, + description = resolved.description.orEmpty(), + ) + ) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.back() } + .launchIn(this) + } + + Column { + AppBarWithTitle( + title = stringResource(R.string.title_setGroupDescription), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { keyboard.hideIfVisible { flowNavigator.back() } }, + ) + val draft = state.draft + CodeScaffold( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + bottomBar = { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding( + top = CodeTheme.dimens.grid.x6, + bottom = CodeTheme.dimens.staticGrid.x4, + ).imePadding(), + text = stringResource(R.string.action_save), + enabled = state.canSubmit, + isLoading = state.processingState.loading, + isSuccess = state.processingState.success, + onClick = { + keyboard.hideIfVisible { + viewModel.dispatchEvent(EditGroupDescriptionViewModel.Event.SaveClicked) + } + }, + ) + }, + ) { padding -> + val focusRequester = remember { FocusRequester() } + Column( + modifier = Modifier + .padding(padding) + .padding(top = CodeTheme.dimens.staticGrid.x4), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x2), + ) { + DisplayTextInput( + state = state.fieldState, + placeholder = stringResource(R.string.hint_groupDescription), + style = CodeTheme.typography.textLarge.copy(color = CodeTheme.colors.textMain), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + minLines = 3, + maxLines = 8, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + imeAction = ImeAction.Default, + ), + ) + + Text( + modifier = Modifier.fillMaxWidth(), + text = draft.remaining.toString(), + style = CodeTheme.typography.textSmall, + color = if (draft.remaining < 0) { + CodeTheme.colors.errorText + } else { + CodeTheme.colors.textSecondary + }, + textAlign = TextAlign.End, + ) + + if (draft.moderated) { + Text( + text = stringResource(R.string.error_groupDescriptionNotAllowed), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.errorText, + ) + } + } + + RequestFocusWhenSettled(focusRequester) + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupDescriptionViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupDescriptionViewModel.kt new file mode 100644 index 0000000000..3f23ea7364 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupDescriptionViewModel.kt @@ -0,0 +1,150 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.lifecycle.viewModelScope +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.models.EditChatError +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import com.getcode.view.LoadingSuccessState +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +/** + * The group's description. Seeded once by the screen from the conversation, and saved through the + * same ask-then-send gate as the other group edits. + * + * A moderated description is the one failure shown beside the field, since it is the text that + * has to change and the field is where that happens. Anything else is a dialog and leaves the + * draft as it was. + */ +@HiltViewModel +class EditGroupDescriptionViewModel @Inject constructor( + dispatchers: DispatcherProvider, + private val chatCoordinator: ChatCoordinator, + private val resources: ResourceHelper, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, +) { + data class State( + val fieldState: TextFieldState = TextFieldState(), + val chatId: ChatId? = null, + val original: String = "", + /** The text the server refused. The refusal describes it and goes away with the next edit. */ + val moderatedText: String? = null, + val processingState: LoadingSuccessState = LoadingSuccessState(), + ) { + val draft: GroupDescriptionDraft + get() = fieldState.text.toString().let { + GroupDescriptionDraft(original = original, text = it, moderated = it == moderatedText) + } + + val canSubmit: Boolean + get() = draft.canSave && processingState.isIdle && chatId != null + } + + sealed interface Event { + data class Initialize(val chatId: ChatId, val description: String) : Event + + /** Save, pressed: proposes the change; [SubmitDescription] is what the prompt's action sends. */ + data object SaveClicked : Event + data object SubmitDescription : Event + data class OnModerated(val text: String) : Event + data class UpdateProcessingState( + val loading: Boolean = false, + val success: Boolean = false, + ) : Event + + data object OnDescriptionAccepted : Event + } + + init { + eventFlow + .filterIsInstance() + .onEach { event -> + stateFlow.value.fieldState.setTextAndPlaceCursorAtEnd(event.description) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + if (!stateFlow.value.canSubmit) return@onEach + + showGroupChangeConfirmation(resources, GroupChangeField.Description) { + viewModelScope.launch { + // The bar dismisses on an animation, and the save's spinner belongs to + // the screen behind it — it would start underneath. + delay(150.milliseconds) + dispatchEvent(Event.SubmitDescription) + } + } + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { submit() } + .launchIn(viewModelScope) + } + + private suspend fun submit() { + val state = stateFlow.value + val chatId = state.chatId ?: return + if (!state.canSubmit) return + + dispatchEvent(Event.UpdateProcessingState(loading = true)) + + chatCoordinator.editChat( + chatId = chatId, + parameters = descriptionOnly(state.draft.edit), + ).onSuccess { + dispatchEvent(Event.UpdateProcessingState(success = true)) + delay(500.milliseconds) + dispatchEvent(Event.OnDescriptionAccepted) + dispatchEvent(Event.UpdateProcessingState()) + }.onFailure { cause -> + dispatchEvent(Event.UpdateProcessingState()) + if (cause is EditChatError.DescriptionModerated) { + dispatchEvent(Event.OnModerated(state.draft.text)) + } else { + showGroupEditAlert( + resources, + groupEditAlert(cause, GroupChangeField.Description), + ) + } + } + } + + internal companion object { + val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + is Event.Initialize -> { state -> + state.copy(chatId = event.chatId, original = event.description, moderatedText = null) + } + + is Event.OnModerated -> { state -> state.copy(moderatedText = event.text) } + Event.SaveClicked -> { state -> state } + Event.SubmitDescription -> { state -> state } + Event.OnDescriptionAccepted -> { state -> state } + is Event.UpdateProcessingState -> { state -> + state.copy( + processingState = state.processingState.copy( + loading = event.loading, + success = event.success, + ) + ) + } + } + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupMenuItems.kt deleted file mode 100644 index 17dcbb7952..0000000000 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupMenuItems.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.flipcash.app.messenger.internal.screens.profile.edit - -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import com.flipcash.app.menu.FullMenuItem -import com.flipcash.app.menu.MenuItem -import com.flipcash.core.R as CoreR -import com.flipcash.features.messenger.R - -/** What the edit list can open. One entry per editable field on `EditChatRequest`. */ -internal sealed interface EditGroupAction { - data object Picture : EditGroupAction - data object Name : EditGroupAction -} - -/** - * The group's picture — node 10187:110373's first row, where the design calls it "Icon". - * - * Labelled Picture rather than the design's "Icon": it writes `EditChatRequest.picture`, and - * picture is the word the rest of the app already uses for it. - * - * Wears the currency creator's icon glyph. That flow edits the same two fields on a token that this - * one edits on a group, so the pair reads as one idea rather than two. - */ -internal data object EditGroupPicture : FullMenuItem() { - override val icon: Painter - @Composable get() = painterResource(CoreR.drawable.ic_currencycreator_icon) - - override val name: String - @Composable get() = stringResource(R.string.title_editGroupPicture) - - override val action: EditGroupAction = EditGroupAction.Picture -} - -/** - * The group's title. - * - * Not in node 10187:110373 — the design's four rows are Icon, Membership Card, Description and - * Social Links — but `EditChatRequest.title` is one of exactly two fields the contract offers, so - * the row exists and takes the picture row's styling rather than inventing its own. - * - * Wears the currency creator's name glyph, for the same reason the picture row wears its icon one. - */ -internal data object EditGroupName : FullMenuItem() { - override val icon: Painter - @Composable get() = painterResource(CoreR.drawable.ic_currencycreator_name) - - override val name: String - @Composable get() = stringResource(R.string.title_editGroupName) - - override val action: EditGroupAction = EditGroupAction.Name -} - -/** - * The edit list, in the order node 10187:110373 puts them in — the picture first. - * - * A function rather than a constant so the list has one definition that both the screen and its - * test read. Membership Card, Description and Social Links are deliberately absent: flipcash2 - * 0.11.0's `EditChatRequest` has `title` and `picture` and nothing else, so a row for any of them - * would be a control with nothing to write. - */ -internal fun editGroupItems(): List> = - listOf(EditGroupPicture, 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 index 03be311429..1087bc1d3c 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 @@ -97,14 +97,25 @@ internal fun EditGroupNameScreen(chatViewModel: ChatViewModel) { ) }, bottomBar = { + Column( + modifier = Modifier + .navigationBarsPadding() + .imePadding(), + ) { + // Only once the end is near, so it reads as a warning and not as furniture. + val remaining = state.remaining + if (remaining < ChatTitle.COUNTER_THRESHOLD) { + Text( + modifier = Modifier.padding(bottom = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.label_groupNameRemaining, remaining), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } CodeButton( modifier = Modifier .fillMaxWidth() - .navigationBarsPadding() - .padding( - top = CodeTheme.dimens.grid.x6, - bottom = CodeTheme.dimens.grid.x3, - ).imePadding(), + .padding(bottom = CodeTheme.dimens.grid.x3), text = stringResource(R.string.action_save), enabled = state.canSubmit, isLoading = state.processingState.loading, @@ -115,13 +126,14 @@ internal fun EditGroupNameScreen(chatViewModel: ChatViewModel) { } }, ) + } }, ) { padding -> val focusRequester = remember { FocusRequester() } Column(modifier = Modifier.padding(padding)) { DisplayTextInput( state = state.titleFieldState, - placeholder = stringResource(R.string.hint_groupName), + placeholder = stringResource(R.string.hint_groupNameEdit), modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester), 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 709c8faea1..d55ffb3bf6 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,14 +5,9 @@ import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.lifecycle.viewModelScope import com.flipcash.analytics.GroupField import com.flipcash.app.analytics.FlipcashAnalytics -import com.flipcash.app.core.moderation.moderationDescription -import com.flipcash.features.messenger.R import com.flipcash.libs.coroutines.DispatcherProvider -import com.flipcash.services.models.EditChatError import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.ChatCoordinator -import com.getcode.manager.BottomBarAction -import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import com.getcode.view.LoadingSuccessState @@ -60,6 +55,9 @@ class EditGroupNameViewModel @Inject constructor( val isValid: Boolean get() = ChatTitle.isValid(titleFieldState.text) + val remaining: Int + get() = ChatTitle.remaining(titleFieldState.text) + /** Nothing to send when the title is what it already was. */ val isChanged: Boolean get() = ChatTitle.normalize(titleFieldState.text) != savedTitle.trim() @@ -108,21 +106,14 @@ class EditGroupNameViewModel @Inject constructor( // 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), - 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, - ) + showGroupChangeConfirmation(resources, GroupChangeField.Name) { + 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) + } + } }.launchIn(viewModelScope) eventFlow @@ -158,30 +149,8 @@ class EditGroupNameViewModel @Inject constructor( }.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), - ) - } + showGroupEditAlert(resources, groupEditAlert(cause, GroupChangeField.Name)) } internal companion object { 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 9c0137b019..e74281ccb6 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 @@ -18,7 +18,6 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.models.chat.RejectionReason import com.flipcash.shared.chat.ChatCoordinator -import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ContentReader import com.getcode.util.resources.ResourceHelper @@ -156,21 +155,14 @@ class EditGroupPictureViewModel @Inject constructor( .onEach { if (!stateFlow.value.canSubmit) return@onEach - BottomBarManager.showAlert( - title = resources.getString(R.string.prompt_title_changeGroupPicture), - message = resources.getString(R.string.prompt_description_changeGroupPicture), - actions = listOf( - 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. - delay(150.milliseconds) - dispatchEvent(Event.SubmitPicture) - } - } - ), - showCancel = true, - ) + showGroupChangeConfirmation(resources, GroupChangeField.Picture) { + 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) + } + } }.launchIn(viewModelScope) eventFlow @@ -243,27 +235,10 @@ class EditGroupPictureViewModel @Inject constructor( } 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), - ) - } + // Storage took the blob and EditChat would not have it, so the pick is what has to + // change — it is dropped rather than left on screen inviting the same upload again. + if (cause is EditChatError.PictureBlobNotAccepted) dispatchEvent(Event.DiscardPendingImage) + showGroupEditAlert(resources, groupEditAlert(cause, GroupChangeField.Picture)) } private fun rejectImage(title: Int, message: Int) { 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 54b096346d..c2b19fd8df 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 @@ -1,39 +1,58 @@ package com.flipcash.app.messenger.internal.screens.profile.edit import android.os.Parcelable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.chat.ChatStep -import com.flipcash.app.menu.MenuList +import com.flipcash.app.messenger.internal.ChatSubject +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.screens.profile.GroupBalanceRequirements +import com.flipcash.app.messenger.internal.screens.profile.holdingLabel import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.shared.common.ui.profile.BalanceRequirementsCard +import com.flipcash.shared.common.ui.profile.CoverAndPhoto +import com.flipcash.shared.common.ui.profile.FieldCard import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.theme.CodeScaffold /** - * The group's edit list — node 10187:110373. + * The group's edit screen: its cover and picture, then a card for each text field, then the + * balance requirements, which are shown and not editable. * - * 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. + * Every field opens its own editor and saves on its own, so nothing is held here. The screen is + * the door to those editors and leaves the moment the viewer stops being allowed through it: if + * `canEdit` goes false while it is open (a role change arriving on the stream), it pops. The + * server re-checks on every write regardless. */ @Composable -internal fun EditGroupScreen() { +internal fun EditGroupScreen(chatViewModel: ChatViewModel) { val flowNavigator = rememberFlowNavigator() + val state by chatViewModel.stateFlow.collectAsStateWithLifecycle() + val group = state.subject as? ChatSubject.Group + val canEdit = state.viewerState?.permissions?.canEdit + + LaunchedEffect(canEdit) { + if (canEdit == false) flowNavigator.back() + } + + val requirements = remember(group?.rules) { GroupBalanceRequirements.from(group?.rules) } + val tokens = state.ruleTokens + val grid = CodeTheme.dimens.staticGrid CodeScaffold( topBar = { @@ -44,18 +63,54 @@ internal fun EditGroupScreen() { ) }, ) { innerPadding -> - MenuList( + Column( 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) - } - }, - ) + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = CodeTheme.dimens.inset) + .padding(vertical = grid.x5), + ) { + CoverAndPhoto( + cover = group?.coverPicture, + avatar = group?.picture, + displayName = group?.groupTitle.orEmpty(), + access = BlobAccessContext.Owned, + changeCoverLabel = stringResource(R.string.action_changeCover), + changePhotoLabel = stringResource(R.string.action_changePhoto), + onChangeCover = { flowNavigator.navigateTo(ChatStep.EditGroupCover) }, + onChangePhoto = { flowNavigator.navigateTo(ChatStep.EditGroupPicture) }, + ) + + Column( + modifier = Modifier.padding(top = grid.x4), + verticalArrangement = Arrangement.spacedBy(grid.x2), + ) { + FieldCard( + title = stringResource(R.string.label_editGroupName), + value = group?.groupTitle?.takeIf { it.isNotEmpty() }, + placeholder = stringResource(R.string.placeholder_editGroupName), + onClick = { flowNavigator.navigateTo(ChatStep.EditGroupName) }, + ) + FieldCard( + title = stringResource(R.string.label_editGroupDescription), + value = group?.description?.takeIf { it.isNotEmpty() }, + placeholder = stringResource(R.string.placeholder_editGroupDescription), + onClick = { flowNavigator.navigateTo(ChatStep.EditGroupDescription) }, + maxLines = 3, + ) + } + + if (requirements != null) { + BalanceRequirementsCard( + modifier = Modifier.padding(top = grid.x6), + join = requirements.join?.let { holdingLabel(it, tokens) }, + chat = requirements.chat?.let { holdingLabel(it, tokens) }, + yourBalance = null, + compact = true, + footnote = stringResource(R.string.footer_groupBalanceRequirements), + ) + } + } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/GroupChange.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/GroupChange.kt new file mode 100644 index 0000000000..fb55a55f44 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/GroupChange.kt @@ -0,0 +1,113 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import androidx.annotation.StringRes +import com.flipcash.features.messenger.R +import com.flipcash.services.models.EditChatError +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper + +/** + * What an edit changes, for the two things every edit says to the user: the prompt before it is + * sent and the explanation when it fails. Each is visible to everyone in the group, so none of + * them leaves the device on the strength of one tap. + */ +internal enum class GroupChangeField( + @StringRes val promptTitle: Int, + @StringRes val promptMessage: Int, + @StringRes val action: Int, + @StringRes val failedTitle: Int, +) { + Name( + R.string.prompt_title_changeGroupName, + R.string.prompt_description_changeGroupName, + R.string.action_changeGroupName, + R.string.error_title_groupNameSaveFailed, + ), + Picture( + R.string.prompt_title_changeGroupPicture, + R.string.prompt_description_changeGroupPicture, + R.string.action_changeGroupPicture, + R.string.error_title_groupPhotoSaveFailed, + ), + Cover( + R.string.prompt_title_changeGroupCover, + R.string.prompt_description_changeGroupCover, + R.string.action_changeGroupCover, + R.string.error_title_groupPhotoSaveFailed, + ), + Description( + R.string.prompt_title_changeGroupDescription, + R.string.prompt_description_changeGroupDescription, + R.string.action_changeGroupDescription, + R.string.error_title_groupDescriptionSaveFailed, + ), +} + +/** + * The prompt that stands between Save and the call: a destructive [GroupChangeField.action] + * followed by Cancel. Only the action runs [onConfirm]; dismissing it sends nothing. + */ +internal fun showGroupChangeConfirmation( + resources: ResourceHelper, + field: GroupChangeField, + onConfirm: () -> Unit, +) { + BottomBarManager.showAlert( + title = resources.getString(field.promptTitle), + message = resources.getString(field.promptMessage), + actions = listOf(BottomBarAction(resources.getString(field.action)) { onConfirm() }), + showCancel = true, + ) +} + +/** What a failed `EditChat` tells the user. [isError] picks the error styling over the alert one. */ +internal data class GroupEditAlert( + @StringRes val title: Int, + @StringRes val message: Int, + val isError: Boolean = false, +) + +/** + * `EditChatResponse.Result`, less `OK`, as the user should hear it for an edit of [field]. + * + * A moderated description is not here: the description screen shows it inline beside the field it + * refers to, and reaches for this only for what it does not handle itself. + */ +internal fun groupEditAlert(cause: Throwable, field: GroupChangeField): GroupEditAlert = when (cause) { + is EditChatError.TitleModerated -> GroupEditAlert( + R.string.error_title_groupNameNotAllowed, + R.string.error_description_groupNameNotAllowed, + ) + + is EditChatError.PictureBlobNotAccepted, + is EditChatError.CoverPictureBlobNotAccepted -> GroupEditAlert( + R.string.error_title_groupPhotoNotAllowed, + R.string.error_description_groupPhotoNotAllowed, + ) + + is EditChatError.Denied -> GroupEditAlert( + R.string.error_title_groupEditDenied, + R.string.error_description_groupEditDenied, + ) + + is EditChatError.NotFound -> GroupEditAlert( + R.string.error_title_groupEditNotFound, + R.string.error_description_groupEditNotFound, + ) + + else -> GroupEditAlert(field.failedTitle, R.string.error_description_groupEditFailed, isError = true) +} + +internal fun showGroupEditAlert( + resources: ResourceHelper, + alert: GroupEditAlert, +) { + val title = resources.getString(alert.title) + val message = resources.getString(alert.message) + if (alert.isError) { + BottomBarManager.showError(title = title, message = message) + } else { + BottomBarManager.showAlert(title = title, message = message) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/GroupDescriptionDraft.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/GroupDescriptionDraft.kt new file mode 100644 index 0000000000..c1fb6a04e1 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/GroupDescriptionDraft.kt @@ -0,0 +1,29 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import com.flipcash.services.models.chat.DescriptionEdit + +/** + * The description being edited. [original] is the stored one an edit is measured against; [moderated] + * is the server's refusal of exactly this [text]. + * + * `EditChatRequest.Description.value` caps at [MAX], counted in code points as the server counts + * runes. An empty description is valid: it is the way to clear one. + */ +data class GroupDescriptionDraft( + val original: String, + val text: String, + val moderated: Boolean = false, +) { + val remaining: Int get() = MAX - text.codePointCount(0, text.length) + + /** Within the cap and different from what the group already carries, whitespace aside. */ + val canSave: Boolean get() = remaining >= 0 && text.trim() != original.trim() + + /** What to send: a description with nothing left of it is a [DescriptionEdit.Clear]. */ + val edit: DescriptionEdit + get() = text.trim().let { if (it.isEmpty()) DescriptionEdit.Clear else DescriptionEdit.Set(it) } + + companion object { + const val MAX = 160 + } +} 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 index 64a8b753dd..e2dbeff4d6 100644 --- 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 @@ -1,14 +1,15 @@ package com.flipcash.app.messenger.internal.screens.profile.edit import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.DescriptionEdit 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 edits the edit screens 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. + * mechanism: each of these carries its own field and no other. * 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 @@ -19,3 +20,21 @@ internal fun titleOnly(title: CharSequence): EditChatParameters = internal fun pictureOnly(blobId: BlobId): EditChatParameters = EditChatParameters(picture = blobId) + +internal fun coverOnly(blobId: BlobId): EditChatParameters = + EditChatParameters(coverPicture = blobId) + +/** + * A description-only edit. A [DescriptionEdit.Set] is sent trimmed, and one with nothing left of + * it is sent as [DescriptionEdit.Clear]: the wire tells "leave it" (wrapper unset) from "clear it" + * (wrapper set, empty value), and an empty set would be the same thing spelled the wrong way. + */ +internal fun descriptionOnly(edit: DescriptionEdit): EditChatParameters { + val normalized = when (edit) { + DescriptionEdit.Clear -> edit + is DescriptionEdit.Set -> edit.value.trim().let { + if (it.isEmpty()) DescriptionEdit.Clear else DescriptionEdit.Set(it) + } + } + return EditChatParameters(description = normalized) +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupCoverViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupCoverViewModelTest.kt new file mode 100644 index 0000000000..07890969a7 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupCoverViewModelTest.kt @@ -0,0 +1,149 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import android.net.Uri +import com.flipcash.app.blob.BlobStorageCoordinator +import com.flipcash.app.blob.ImageUploadPreparer +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.libs.coroutines.TestDispatcherProvider +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.ChatMetadata +import com.flipcash.services.models.chat.EditChatParameters +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ContentReader +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The cover editor: the iOS `EditGroupModelTests` cover cases, over Android's upload pipeline. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class EditGroupCoverViewModelTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val chatCoordinator = mockk(relaxed = true) + private val blobStorage = mockk(relaxed = true) + private val imagePreparer = mockk(relaxed = true) + private val contentReader = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val chatId = ChatId(ByteArray(16) { 7 }.toList()) + private val blob = BlobId(ByteArray(32) { 5 }) + + @Before + fun setUp() { + BottomBarManager.clear() + every { blobStorage.policy } returns flowOf(null) + coEvery { contentReader.readBytes(any()) } returns ByteArray(4) + } + + @After + fun tearDown() = BottomBarManager.clear() + + private fun viewModel() = EditGroupCoverViewModel( + dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), + chatCoordinator = chatCoordinator, + blobStorage = blobStorage, + imagePreparer = imagePreparer, + contentReader = contentReader, + resources = resources, + ).apply { + dispatchEvent(EditGroupCoverViewModel.Event.Initialize(chatId, cover = null)) + } + + private fun EditGroupCoverViewModel.pick() = dispatchEvent( + EditGroupCoverViewModel.Event.OnImageCached(Uri.parse("file:///cover.jpg"), "image/jpeg") + ) + + @Test + fun `save is shut until a cover is picked`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel() + assertFalse(vm.stateFlow.value.canSubmit) + + vm.pick() + advanceUntilIdle() + + assertTrue(vm.stateFlow.value.canSubmit) + } + + @Test + fun `a cover edit uploads once and sends the cover blob only`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { blobStorage.upload(any(), any()) } returns Result.success(blob) + val sent = slot() + coEvery { chatCoordinator.editChat(any(), capture(sent)) } returns + Result.success(mockk(relaxed = true)) + + val vm = viewModel() + vm.pick() + vm.dispatchEvent(EditGroupCoverViewModel.Event.SubmitCover) + advanceUntilIdle() + + assertEquals(blob, sent.captured.coverPicture) + assertNull(sent.captured.title) + assertNull(sent.captured.picture) + assertNull(sent.captured.description) + coVerify(exactly = 1) { blobStorage.upload(any(), any()) } + coVerify(exactly = 1) { chatCoordinator.editChat(chatId, any()) } + } + + @Test + fun `a cover the upload refused never reaches the edit`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { blobStorage.upload(any(), any()) } returns Result.failure(RuntimeException("refused")) + + val vm = viewModel() + vm.pick() + vm.dispatchEvent(EditGroupCoverViewModel.Event.SubmitCover) + advanceUntilIdle() + + coVerify(exactly = 0) { chatCoordinator.editChat(any(), any()) } + } + + @Test + fun `a cover the server will not accept is dropped and explained`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { blobStorage.upload(any(), any()) } returns Result.success(blob) + coEvery { chatCoordinator.editChat(any(), any()) } returns + Result.failure(EditChatError.CoverPictureBlobNotAccepted()) + + val vm = viewModel() + vm.pick() + vm.dispatchEvent(EditGroupCoverViewModel.Event.SubmitCover) + advanceUntilIdle() + + assertFalse(vm.stateFlow.value.canSubmit, "the refused pick must not be left to save again") + assertEquals(1, BottomBarManager.messages.value.size) + } + + @Test + fun `saving asks before it uploads`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel() + vm.pick() + + vm.dispatchEvent(EditGroupCoverViewModel.Event.SaveClicked) + advanceUntilIdle() + + coVerify(exactly = 0) { blobStorage.upload(any(), any()) } + coVerify(exactly = 0) { chatCoordinator.editChat(any(), any()) } + assertEquals(1, BottomBarManager.messages.value.size) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupDescriptionViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupDescriptionViewModelTest.kt new file mode 100644 index 0000000000..877b78534e --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupDescriptionViewModelTest.kt @@ -0,0 +1,185 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.models.EditChatError +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.DescriptionEdit +import com.flipcash.services.models.chat.EditChatParameters +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 io.mockk.slot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** The iOS `EditGroupDescriptionModelTests`. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class EditGroupDescriptionViewModelTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val chatCoordinator = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val chatId = ChatId(ByteArray(16) { 7 }.toList()) + + @Before + fun setUp() = BottomBarManager.clear() + + @After + fun tearDown() = BottomBarManager.clear() + + private fun viewModel(description: String = "Hello") = EditGroupDescriptionViewModel( + dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), + chatCoordinator = chatCoordinator, + resources = resources, + ).apply { + dispatchEvent(EditGroupDescriptionViewModel.Event.Initialize(chatId, description)) + } + + private fun EditGroupDescriptionViewModel.type(text: String) = + stateFlow.value.fieldState.setTextAndPlaceCursorAtEnd(text) + + private val EditGroupDescriptionViewModel.draft get() = stateFlow.value.draft + + @Test + fun `the field opens on the current description with the characters left`() = + runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel("Hello") + advanceUntilIdle() + + assertEquals("Hello", vm.stateFlow.value.fieldState.text.toString()) + assertEquals(155, vm.draft.remaining) + } + + @Test + fun `save is shut when unchanged or over the limit and open once cleared`() = + runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel("Hello") + advanceUntilIdle() + assertFalse(vm.stateFlow.value.canSubmit) + + vm.type("x".repeat(161)) + advanceUntilIdle() + assertFalse(vm.stateFlow.value.canSubmit) + + vm.type("") + advanceUntilIdle() + assertTrue(vm.stateFlow.value.canSubmit) + } + + @Test + fun `a changed description is sent trimmed`() = runTest(mainCoroutineRule.dispatcher) { + val sent = slot() + coEvery { chatCoordinator.editChat(any(), capture(sent)) } returns + Result.success(mockk(relaxed = true)) + val vm = viewModel("Hello") + advanceUntilIdle() + + vm.type(" Good boys only ") + advanceUntilIdle() + vm.dispatchEvent(EditGroupDescriptionViewModel.Event.SubmitDescription) + advanceUntilIdle() + + assertEquals(DescriptionEdit.Set("Good boys only"), sent.captured.description) + } + + @Test + fun `an emptied description is sent as a clear`() = runTest(mainCoroutineRule.dispatcher) { + val sent = slot() + coEvery { chatCoordinator.editChat(any(), capture(sent)) } returns + Result.success(mockk(relaxed = true)) + val vm = viewModel("Hello") + advanceUntilIdle() + + vm.type(" ") + advanceUntilIdle() + vm.dispatchEvent(EditGroupDescriptionViewModel.Event.SubmitDescription) + advanceUntilIdle() + + assertEquals(DescriptionEdit.Clear, sent.captured.description) + } + + @Test + fun `a moderated description shows inline, with no dialog and save still open`() = + runTest(mainCoroutineRule.dispatcher) { + coEvery { chatCoordinator.editChat(any(), any()) } returns + Result.failure(EditChatError.DescriptionModerated(mockk(relaxed = true))) + val vm = viewModel("Hello") + advanceUntilIdle() + + vm.type("Something else") + advanceUntilIdle() + vm.dispatchEvent(EditGroupDescriptionViewModel.Event.SubmitDescription) + advanceUntilIdle() + + assertTrue(vm.draft.moderated) + assertTrue(BottomBarManager.messages.value.isEmpty()) + assertTrue(vm.stateFlow.value.canSubmit) + } + + @Test + fun `editing the text clears the moderation error`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { chatCoordinator.editChat(any(), any()) } returns + Result.failure(EditChatError.DescriptionModerated(mockk(relaxed = true))) + val vm = viewModel("Hello") + advanceUntilIdle() + vm.type("Something else") + advanceUntilIdle() + vm.dispatchEvent(EditGroupDescriptionViewModel.Event.SubmitDescription) + advanceUntilIdle() + + vm.type("Something kinder") + advanceUntilIdle() + + assertFalse(vm.draft.moderated) + } + + @Test + fun `any other failure is a dialog and not an inline error`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { chatCoordinator.editChat(any(), any()) } returns Result.failure(RuntimeException("boom")) + val vm = viewModel("Hello") + advanceUntilIdle() + + vm.type("Something else") + advanceUntilIdle() + vm.dispatchEvent(EditGroupDescriptionViewModel.Event.SubmitDescription) + advanceUntilIdle() + + assertFalse(vm.draft.moderated) + assertEquals(1, BottomBarManager.messages.value.size) + assertTrue(vm.stateFlow.value.canSubmit) + } + + @Test + fun `saving asks before it sends`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel("Hello") + advanceUntilIdle() + vm.type("Something else") + advanceUntilIdle() + + vm.dispatchEvent(EditGroupDescriptionViewModel.Event.SaveClicked) + advanceUntilIdle() + + coVerify(exactly = 0) { chatCoordinator.editChat(any(), any()) } + assertEquals(1, BottomBarManager.messages.value.size) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/GroupChangeDialogTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/GroupChangeDialogTest.kt new file mode 100644 index 0000000000..5779135ec7 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/GroupChangeDialogTest.kt @@ -0,0 +1,76 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import androidx.test.core.app.ApplicationProvider +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import io.mockk.every +import io.mockk.mockk +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import android.content.Context +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** The iOS `GroupChangeDialogTests`: the prompt in front of every group edit, per field. */ +@RunWith(RobolectricTestRunner::class) +class GroupChangeDialogTest { + + private val context = ApplicationProvider.getApplicationContext() + private val resources = mockk().also { + every { it.getString(any()) } answers { context.getString(firstArg()) } + } + + @Before + fun setUp() = BottomBarManager.clear() + + @After + fun tearDown() = BottomBarManager.clear() + + private val expected = mapOf( + GroupChangeField.Name to ("Group Name" to "This will change the group name for everyone in it"), + GroupChangeField.Picture to ("Group Picture" to "This will change the group picture for everyone in it"), + GroupChangeField.Cover to ("Group Cover" to "This will change the group cover for everyone in it"), + GroupChangeField.Description to ("Group Description" to "This will change the group description for everyone in it"), + ) + + @Test + fun `each field names itself in the title and the action, and says who it affects`() { + expected.forEach { (field, copy) -> + BottomBarManager.clear() + showGroupChangeConfirmation(resources, field) {} + + val message = BottomBarManager.messages.value.single() + assertEquals("Change ${copy.first}?", message.title, field.name) + assertEquals(copy.second, message.subtitle, field.name) + assertEquals("Change ${copy.first}", message.actions.first().text.text, field.name) + assertTrue(message.title.contains("Group")) + assertFalse(message.subtitle.contains("your")) + } + } + + @Test + fun `the change action is the only one and cancel follows it`() { + showGroupChangeConfirmation(resources, GroupChangeField.Name) {} + + val message = BottomBarManager.messages.value.single() + assertEquals(1, message.actions.size) + assertTrue(message.showCancel) + } + + @Test + fun `only the change action runs the handler`() { + var confirmed = 0 + showGroupChangeConfirmation(resources, GroupChangeField.Name) { confirmed++ } + + BottomBarManager.clear() + assertEquals(0, confirmed) + + showGroupChangeConfirmation(resources, GroupChangeField.Name) { confirmed++ } + BottomBarManager.messages.value.last().actions.first().onClick() + assertEquals(1, confirmed) + } +} 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 index 5d07226cef..b7a79e2609 100644 --- 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 @@ -1,6 +1,7 @@ package com.flipcash.app.messenger.internal.screens.profile.edit import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.DescriptionEdit import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -37,4 +38,41 @@ class PartialEditsTest { assertEquals(blob, parameters.picture) assertNull(parameters.title, "a picture change must not touch the group's name") } + + @Test + fun `changing the cover sends a cover blob and nothing else`() { + val blob = BlobId(byteArrayOf(4, 5, 6)) + + val parameters = coverOnly(blob) + + assertEquals(blob, parameters.coverPicture) + assertNull(parameters.title) + assertNull(parameters.picture) + assertNull(parameters.description) + } + + @Test + fun `a description edit sends the description and nothing else`() { + val parameters = descriptionOnly(DescriptionEdit.Set("Good boys only")) + + assertEquals(DescriptionEdit.Set("Good boys only"), parameters.description) + assertNull(parameters.title) + assertNull(parameters.picture) + assertNull(parameters.coverPicture) + } + + @Test + fun `description text is sent trimmed`() { + assertEquals( + DescriptionEdit.Set("Good boys only"), + descriptionOnly(DescriptionEdit.Set(" Good boys only \n")).description, + ) + } + + @Test + fun `an emptied description is a clear and not an empty set`() { + assertEquals(DescriptionEdit.Clear, descriptionOnly(DescriptionEdit.Set("")).description) + assertEquals(DescriptionEdit.Clear, descriptionOnly(DescriptionEdit.Set(" \n ")).description) + assertEquals(DescriptionEdit.Clear, descriptionOnly(DescriptionEdit.Clear).description) + } } diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileScreenContent.kt index 33ba51630e..a9999b7980 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileScreenContent.kt @@ -1,55 +1,25 @@ package com.flipcash.app.myaccount.internal.editprofile -import androidx.compose.foundation.background -import androidx.compose.foundation.border -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.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import com.flipcash.app.core.userprofile.UpdateProfileStep import com.flipcash.core.R +import com.flipcash.shared.common.ui.profile.CoverAndPhoto +import com.flipcash.shared.common.ui.profile.FieldCard import com.flipcash.services.models.chat.BlobAccessContext -import com.flipcash.shared.common.ui.ContactAvatar -import com.flipcash.shared.common.ui.profile.ProfileCover import com.getcode.theme.CodeTheme -import com.getcode.theme.White05 -import com.getcode.ui.components.ListItemDefaults -import androidx.compose.ui.unit.Dp -import androidx.compose.foundation.layout.offset -import com.getcode.ui.components.glass.floatingGlass -import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.rememberHazeState - -// Carried from the iOS screen. -private val CoverHeight = 126.dp -private val AvatarSize = 68.dp -private val BadgeSize = 28.dp -private val BadgeIconSize = 16.dp -private val BadgeOffsetX = 6.dp -private val BadgeOffsetY = 2.dp @Composable internal fun EditProfileScreenContent( @@ -67,7 +37,12 @@ internal fun EditProfileScreenContent( val openStep = { step: UpdateProfileStep -> dispatch(EditProfileViewModel.Event.OpenStep(step)) } CoverAndPhoto( - state = state, + cover = state.cover, + avatar = state.avatar, + displayName = state.displayName, + access = BlobAccessContext.Owned, + changeCoverLabel = stringResource(R.string.action_changeCover), + changePhotoLabel = stringResource(R.string.action_changePhoto), onChangeCover = { openStep(UpdateProfileStep.Cover) }, onChangePhoto = { openStep(UpdateProfileStep.Photo) }, ) @@ -122,139 +97,3 @@ internal fun EditProfileScreenContent( } } } - -/** The cover card with the avatar hanging off its bottom edge, half over it. */ -@Composable -private fun CoverAndPhoto( - state: EditProfileViewModel.State, - onChangeCover: () -> Unit, - onChangePhoto: () -> Unit, -) { - val grid = CodeTheme.dimens.staticGrid - // The cover is what the Change cover chip frosts, as iOS's CoverChip is glass over it. - val hazeState = rememberHazeState() - Box(modifier = Modifier.fillMaxWidth().height(CoverHeight + AvatarSize / 2)) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(CoverHeight) - .clip(CodeTheme.shapes.medium) - .clickable(onClick = onChangeCover), - ) { - ProfileCover( - image = state.cover, - access = BlobAccessContext.Owned, - height = Dp.Unspecified, - modifier = Modifier - .fillMaxSize() - .hazeSource(hazeState), - ) - Text( - text = stringResource(R.string.action_changeCover), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textMain, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(grid.x2) - .floatingGlass(hazeState) - .padding(horizontal = grid.x2, vertical = grid.x1), - ) - } - - Row( - modifier = Modifier - .align(Alignment.BottomStart) - .padding(start = grid.x3) - .clickable(onClick = onChangePhoto), - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(grid.x2), - ) { - Box { - ContactAvatar( - image = state.avatar, - displayName = state.displayName, - access = BlobAccessContext.Owned, - modifier = Modifier - .size(AvatarSize) - .clip(CircleShape) - .border(grid.x1, CodeTheme.colors.background, CircleShape), - ) - CameraBadge( - modifier = Modifier - .align(Alignment.BottomEnd) - .offset(x = BadgeOffsetX, y = BadgeOffsetY), - ) - } - Text( - text = stringResource(R.string.action_changePhoto), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - modifier = Modifier.padding(bottom = grid.x1), - ) - } - } -} - -@Composable -private fun CameraBadge(modifier: Modifier = Modifier) { - Box( - modifier = modifier - .size(BadgeSize) - .clip(CircleShape) - // Opaque, so the avatar doesn't show through the tinted fill. - .background(CodeTheme.colors.background) - .background(White05), - contentAlignment = Alignment.Center, - ) { - Icon( - painter = painterResource(R.drawable.ic_camera), - contentDescription = null, - tint = CodeTheme.colors.textMain, - modifier = Modifier.size(BadgeIconSize), - ) - } -} - -/** A tappable card: the field's title over its current value, with the disclosure chevron. */ -@Composable -private fun FieldCard( - title: String, - value: String?, - placeholder: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - valueIsPrompt: Boolean = false, - maxLines: Int = 1, -) { - val grid = CodeTheme.dimens.staticGrid - Row( - modifier = modifier - .fillMaxWidth() - .clip(CodeTheme.shapes.medium) - .background(White05) - .clickable(onClick = onClick) - .padding(horizontal = grid.x3, vertical = grid.x2), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(grid.x2), - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - style = CodeTheme.typography.caption, - color = CodeTheme.colors.textSecondary, - ) - Text( - text = value ?: placeholder, - style = CodeTheme.typography.textMedium, - color = if (value == null || valueIsPrompt) { - CodeTheme.colors.textSecondary - } else { - CodeTheme.colors.textMain - }, - maxLines = maxLines, - overflow = TextOverflow.Ellipsis, - ) - } - ListItemDefaults.Chevron() - } -} diff --git a/apps/flipcash/shared/common-ui/build.gradle.kts b/apps/flipcash/shared/common-ui/build.gradle.kts index 2e01ea4fa6..a55a5b8f81 100644 --- a/apps/flipcash/shared/common-ui/build.gradle.kts +++ b/apps/flipcash/shared/common-ui/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { implementation(project(":libs:datetime")) implementation(project(":apps:flipcash:shared:theme")) + implementation(libs.bundles.haze) implementation(libs.compose.material) implementation(libs.bundles.haze) api(libs.compose.material.icons.extended) diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/BalanceRequirementsCard.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/BalanceRequirementsCard.kt index 2d2c388e03..7efce6668d 100644 --- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/BalanceRequirementsCard.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/BalanceRequirementsCard.kt @@ -35,6 +35,7 @@ import com.getcode.theme.CodeTheme import com.getcode.theme.White05 private val RowHeight = 56.dp +private val CompactRowHeight = 48.dp private val CardRadius = 12.dp private val RowInset = 16.dp @@ -48,6 +49,9 @@ private val RowInset = 16.dp * When [communityCurrency] is set, a Community Currency row leads the card with that content (the * token's icon and name) and a chevron, and tapping it calls [onCommunityCurrencyClick]. The caller * then passes bare amounts for [join] and [chat], since the token is already named above them. + * + * [compact] is the read-only form on Edit Group: a 16 header, shorter rows with their labels in + * the same 16 as the values, and the [footnote] beneath. The profile's own is the default. */ @Composable fun BalanceRequirementsCard( @@ -57,14 +61,16 @@ fun BalanceRequirementsCard( modifier: Modifier = Modifier, communityCurrency: (@Composable RowScope.() -> Unit)? = null, onCommunityCurrencyClick: () -> Unit = {}, + compact: Boolean = false, + footnote: String? = null, ) { Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 16.dp), ) { Text( text = stringResource(R.string.title_balanceRequirements), - style = CodeTheme.typography.textLarge, + style = if (compact) CodeTheme.typography.textMedium else CodeTheme.typography.textLarge, color = CodeTheme.colors.textMain, ) Column( @@ -80,11 +86,20 @@ fun BalanceRequirementsCard( RequirementRow( label = stringResource(R.string.label_balanceRequirementJoin), value = join ?: stringResource(R.string.label_balanceRequirementNone), + compact = compact, ) RowSeparator() RequirementRow( label = stringResource(R.string.label_balanceRequirementChat), value = chat ?: stringResource(R.string.label_balanceRequirementNone), + compact = compact, + ) + } + if (footnote != null) { + Text( + text = footnote, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, ) } // The balance resolves after the rules; it fades in rather than popping, and keeps its @@ -153,18 +168,18 @@ private fun CommunityCurrencyRow(onClick: () -> Unit, content: @Composable RowSc } @Composable -private fun RequirementRow(label: String, value: String) { +private fun RequirementRow(label: String, value: String, compact: Boolean) { Row( modifier = Modifier .fillMaxWidth() - .height(RowHeight) + .height(if (compact) CompactRowHeight else RowHeight) .padding(horizontal = RowInset), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { Text( text = label, - style = CodeTheme.typography.textSmall, + style = if (compact) CodeTheme.typography.textMedium else CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, ) Text( diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileEditFields.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileEditFields.kt new file mode 100644 index 0000000000..05ce4ae4cf --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileEditFields.kt @@ -0,0 +1,203 @@ +package com.flipcash.shared.common.ui.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.flipcash.core.R +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 +import com.getcode.ui.components.ListItemDefaults +import com.getcode.ui.components.glass.floatingGlass +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState + +/** The cover's height on an edit screen, carried from iOS. */ +val EditCoverHeight = 126.dp + +/** The avatar's diameter on an edit screen, carried from iOS. */ +val EditAvatarSize = 68.dp + +private val BadgeSize = 28.dp +private val BadgeIconSize = 16.dp +private val BadgeOffsetX = 6.dp +private val BadgeOffsetY = 2.dp + +/** + * The cover card with the avatar hanging off its bottom edge, half over it, as the edit screens + * for a profile and for a group draw it. + * + * Takes what to draw and how to caption it, never ids or controllers: the owner of the screen + * decides what the pictures are and where a tap goes. [access] is the surface both pictures are + * read from. The cover is clipped to the medium shape inside the caller's content padding. + */ +@Composable +fun CoverAndPhoto( + cover: MediaItem?, + avatar: MediaItem?, + displayName: String, + access: BlobAccessContext, + changeCoverLabel: String, + changePhotoLabel: String, + onChangeCover: () -> Unit, + onChangePhoto: () -> Unit, + modifier: Modifier = Modifier, + coverHeight: Dp = EditCoverHeight, + avatarSize: Dp = EditAvatarSize, +) { + val grid = CodeTheme.dimens.staticGrid + // The cover is what the Change cover chip frosts, as iOS's CoverChip is glass over it. + val hazeState = rememberHazeState() + Box(modifier = modifier.fillMaxWidth().height(coverHeight + avatarSize / 2)) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(coverHeight) + .clip(CodeTheme.shapes.medium) + .clickable(onClick = onChangeCover), + ) { + ProfileCover( + image = cover, + access = access, + height = Dp.Unspecified, + modifier = Modifier + .fillMaxSize() + .hazeSource(hazeState), + ) + Text( + text = changeCoverLabel, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(grid.x2) + .floatingGlass(hazeState) + .padding(horizontal = grid.x2, vertical = grid.x1), + ) + } + + Row( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = grid.x3) + .clickable(onClick = onChangePhoto), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(grid.x2), + ) { + Box { + ContactAvatar( + image = avatar, + displayName = displayName, + access = access, + modifier = Modifier + .size(avatarSize) + .clip(CircleShape) + .border(grid.x1, CodeTheme.colors.background, CircleShape), + ) + CameraBadge( + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = BadgeOffsetX, y = BadgeOffsetY), + ) + } + Text( + text = changePhotoLabel, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + modifier = Modifier.padding(bottom = grid.x1), + ) + } + } +} + +@Composable +fun CameraBadge(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(BadgeSize) + .clip(CircleShape) + // Opaque, so the avatar doesn't show through the tinted fill. + .background(CodeTheme.colors.background) + .background(White05), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_camera), + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(BadgeIconSize), + ) + } +} + +/** + * A tappable card: the field's title over its current value, with the disclosure chevron. + * + * A null [value] shows [placeholder] in the secondary colour; [valueIsPrompt] does the same for a + * value that is itself a call to action. [maxLines] is 1 for a name and 3 for a description. + */ +@Composable +fun FieldCard( + title: String, + value: String?, + placeholder: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + valueIsPrompt: Boolean = false, + maxLines: Int = 1, +) { + val grid = CodeTheme.dimens.staticGrid + Row( + modifier = modifier + .fillMaxWidth() + .clip(CodeTheme.shapes.medium) + .background(White05) + .clickable(onClick = onClick) + .padding(horizontal = grid.x3, vertical = grid.x2), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(grid.x2), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + Text( + text = value ?: placeholder, + style = CodeTheme.typography.textMedium, + color = if (value == null || valueIsPrompt) { + CodeTheme.colors.textSecondary + } else { + CodeTheme.colors.textMain + }, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + ) + } + ListItemDefaults.Chevron() + } +}