From 434089b784d19fb5476291f283e698f6135de308 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 28 Aug 2026 15:38:18 -0400 Subject: [PATCH 1/2] feat(profile): confirm a profile change before it is written Saving a display name, username, profile picture, or minimum tip wrote straight through on the first tap. Each of those four is an overwrite with no undo, and the username case is worse than the rest: changing a handle releases the old one for anyone to claim. Each screen's Save button now dispatches a confirm event rather than the save event. The ViewModel gate shows a destructive alert whose single action dispatches the original save; Cancel leaves the entry as it is. The save pipelines themselves are unchanged. The prompt is only shown when there is a stored value to replace. A first claim overwrites nothing, and the name step is mandatory during onboarding, where a confirmation would sit on a screen that cannot be backed out of. Minimum tip asks after the below-minimum check, so an amount the server would reject still gets the minimum-tip error instead of a confirm that then fails. Its write moved to a CommitRequested event so it runs on viewModelScope rather than from the dialog callback. --- .../core/src/main/res/values/strings.xml | 17 ++++++++++ .../mintip/MinimumTipEntryViewModel.kt | 34 ++++++++++++++++++- .../internal/name/NameEntryScreen.kt | 4 +-- .../internal/name/NameEntryViewModel.kt | 27 +++++++++++++++ .../internal/photo/PhotoSelectionScreen.kt | 2 +- .../internal/photo/PhotoSelectionViewModel.kt | 27 +++++++++++++++ .../internal/username/UsernameEntryScreen.kt | 4 +-- .../username/UsernameEntryViewModel.kt | 27 +++++++++++++++ 8 files changed, 136 insertions(+), 6 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 0b4ccc7e7..223ee5c2e 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -964,6 +964,23 @@ Something Went Wrong Please try again + + Change Display Name? + Are you sure you want to permanently change your display name? + Change Display Name + Change Username? + Are you sure you want to permanently change your username? You might not be able to get your old username back + Change Username + Change Profile Picture? + Are you sure you want to permanently change your profile picture? + Change Profile Picture + Change Minimum Tip? + Are you sure you want to permanently change your minimum tip? + Change Minimum Tip + No Such Account Nobody has claimed \@%1$s diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryViewModel.kt index 0d627365b..ac47eb5b4 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryViewModel.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryViewModel.kt @@ -9,6 +9,7 @@ import com.flipcash.shared.amountentry.AmountEntryDelegate import com.flipcash.shared.amountentry.AmountEntryLabel import com.flipcash.shared.amountentry.AmountEntryStyle import com.flipcash.shared.payments.TipPaymentDelegate +import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.model.financial.CurrencyCode @@ -71,6 +72,12 @@ internal class MinimumTipEntryViewModel @Inject constructor( /** User asked to save the currently entered amount. */ data object ConfirmRequested : Event + /** + * The entry cleared validation and, when one was already stored, the user confirmed + * replacing it. This is what actually writes to the profile. + */ + data class CommitRequested(val amount: Fiat) : Event + data class UpdateSavingState( val loading: Boolean = false, val success: Boolean = false, @@ -158,8 +165,32 @@ internal class MinimumTipEntryViewModel @Inject constructor( return@onEach } + // Replacing a stored fee asks first; a first one has nothing to overwrite. Asked + // after validation so a rejected amount never gets a confirmation dialog. + if (stateFlow.value.saved == null) { + dispatchEvent(Event.CommitRequested(amount)) + return@onEach + } + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_changeMinimumTip), + message = resources.getString(R.string.prompt_description_changeMinimumTip), + actions = listOf( + BottomBarAction(resources.getString(R.string.action_changeMinimumTip)) { + dispatchEvent(Event.CommitRequested(amount)) + } + ), + showCancel = true, + ) + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { event -> + if (!stateFlow.value.saving.isIdle) return@onEach + dispatchEvent(Event.UpdateSavingState(loading = true)) - profileController.setMinDmChatInitFee(amount) + profileController.setMinDmChatInitFee(event.amount) .onSuccess { viewModelScope.launch { dispatchEvent(Event.UpdateSavingState(success = true)) @@ -207,6 +238,7 @@ internal class MinimumTipEntryViewModel @Inject constructor( ) } is Event.ConfirmRequested -> { state -> state } + is Event.CommitRequested -> { state -> state } is Event.Saved -> { state -> state } } } diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt index 168031b3d..9b198f232 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt @@ -122,7 +122,7 @@ private fun NameEntryScreenContent( isSuccess = state.processingState.success, onClick = { keyboard.hideIfVisible { - dispatchEvent(NameEntryViewModel.Event.CheckName(source)) + dispatchEvent(NameEntryViewModel.Event.ConfirmNameChange(source)) } }, ) @@ -150,7 +150,7 @@ private fun NameEntryScreenContent( ), onKeyboardAction = { keyboard.hideIfVisible { - dispatchEvent(NameEntryViewModel.Event.CheckName(source)) + dispatchEvent(NameEntryViewModel.Event.ConfirmNameChange(source)) } }, ) diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt index aafcfd989..828747a19 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt @@ -14,6 +14,7 @@ import com.flipcash.services.models.ModerationResult import com.flipcash.services.models.SetDisplayNameError import com.flipcash.services.models.TextModerationError import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.model.core.errors.ValidationException import com.getcode.util.resources.ResourceHelper @@ -61,6 +62,12 @@ class NameEntryViewModel @Inject constructor( } sealed interface Event { + /** + * Save was pressed. Replacing a stored name asks first; a first name has nothing to + * overwrite and goes straight to [CheckName]. + */ + data class ConfirmNameChange(val source: DisplayNameSource) : Event + data class CheckName(val source: DisplayNameSource) : Event data class UpdateProcessingState( val loading: Boolean = false, @@ -96,6 +103,25 @@ class NameEntryViewModel @Inject constructor( } }.launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .onEach { event -> + if (stateFlow.value.savedName.isBlank()) { + dispatchEvent(Event.CheckName(event.source)) + return@onEach + } + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_changeDisplayName), + message = resources.getString(R.string.prompt_description_changeDisplayName), + actions = listOf( + BottomBarAction(resources.getString(R.string.action_changeDisplayName)) { + dispatchEvent(Event.CheckName(event.source)) + } + ), + showCancel = true, + ) + }.launchIn(viewModelScope) + eventFlow .filterIsInstance() .onEach { @@ -194,6 +220,7 @@ class NameEntryViewModel @Inject constructor( internal companion object { val updateStateForEvent: (Event) -> (State.() -> State) = { event -> when (event) { + is Event.ConfirmNameChange -> { state -> state } is Event.CheckName -> { state -> state } is Event.OnSavedNameLoaded -> { state -> state.copy(savedName = event.name) } Event.DiscardChanges -> { state -> state } diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionScreen.kt index be47713f6..824094b3f 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionScreen.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionScreen.kt @@ -124,7 +124,7 @@ private fun PhotoSelectionScreenContent( isLoading = state.processingState.loading, isSuccess = state.processingState.success, onClick = { - dispatchEvent(PhotoSelectionViewModel.Event.CheckImage) + dispatchEvent(PhotoSelectionViewModel.Event.ConfirmImageChange) }, ) } diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModel.kt index 640f326f6..7730af93f 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModel.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModel.kt @@ -21,6 +21,7 @@ import com.flipcash.services.models.TextModerationError import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.models.chat.RejectionReason import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.model.core.errors.ValidationException import com.getcode.util.resources.ContentReader @@ -88,6 +89,12 @@ class PhotoSelectionViewModel @Inject constructor( } sealed interface Event { + /** + * Save was pressed. Replacing a stored picture asks first; a first picture has nothing to + * overwrite and goes straight to [CheckImage]. + */ + data object ConfirmImageChange : Event + data object CheckImage : Event /** The stored picture arrived, or changed — including to null when it is unset. */ @@ -124,6 +131,25 @@ class PhotoSelectionViewModel @Inject constructor( .launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .onEach { + if (stateFlow.value.savedPicture == null) { + dispatchEvent(Event.CheckImage) + return@onEach + } + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_changeProfilePicture), + message = resources.getString(R.string.prompt_description_changeProfilePicture), + actions = listOf( + BottomBarAction(resources.getString(R.string.action_changeProfilePicture)) { + dispatchEvent(Event.CheckImage) + } + ), + showCancel = true, + ) + }.launchIn(viewModelScope) + eventFlow .filterIsInstance() .mapNotNull { event -> @@ -405,6 +431,7 @@ class PhotoSelectionViewModel @Inject constructor( internal val updateStateForEvent: (Event) -> (State.() -> State) = { event -> when (event) { + Event.ConfirmImageChange -> { state -> state } Event.CheckImage -> { state -> state } is Event.OnSavedPictureLoaded -> { state -> state.copy(savedPicture = event.picture) diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryScreen.kt index f3ba8de84..ae981da75 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryScreen.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryScreen.kt @@ -114,7 +114,7 @@ private fun UsernameEntryScreenContent( isSuccess = state.processingState.success, onClick = { keyboard.hideIfVisible { - dispatchEvent(UsernameEntryViewModel.Event.CheckUsername) + dispatchEvent(UsernameEntryViewModel.Event.ConfirmUsernameChange) } }, ) @@ -140,7 +140,7 @@ private fun UsernameEntryScreenContent( ), onKeyboardAction = { keyboard.hideIfVisible { - dispatchEvent(UsernameEntryViewModel.Event.CheckUsername) + dispatchEvent(UsernameEntryViewModel.Event.ConfirmUsernameChange) } }, inputTransformation = UsernameInputTransformation, diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt index 84fa7f78d..e5622396d 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt @@ -12,6 +12,7 @@ import com.flipcash.services.models.MinUsernameLength import com.flipcash.services.models.ModerationResult import com.flipcash.services.models.SetUsernameError import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.model.core.errors.ValidationException import com.getcode.opencode.model.financial.Fiat @@ -71,6 +72,12 @@ class UsernameEntryViewModel @Inject constructor( } sealed interface Event { + /** + * Save was pressed. Changing a claimed handle asks first — the old one is released and + * anyone can take it — while a first claim goes straight to [CheckUsername]. + */ + data object ConfirmUsernameChange : Event + data object CheckUsername : Event data class UpdateProcessingState( val loading: Boolean = false, @@ -106,6 +113,25 @@ class UsernameEntryViewModel @Inject constructor( } }.launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .onEach { + if (stateFlow.value.savedUsername.isBlank()) { + dispatchEvent(Event.CheckUsername) + return@onEach + } + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_changeUsername), + message = resources.getString(R.string.prompt_description_changeUsername), + actions = listOf( + BottomBarAction(resources.getString(R.string.action_changeUsername)) { + dispatchEvent(Event.CheckUsername) + } + ), + showCancel = true, + ) + }.launchIn(viewModelScope) + eventFlow .filterIsInstance() .onEach { @@ -225,6 +251,7 @@ class UsernameEntryViewModel @Inject constructor( internal companion object { val updateStateForEvent: (Event) -> (State.() -> State) = { event -> when (event) { + Event.ConfirmUsernameChange -> { state -> state } Event.CheckUsername -> { state -> state } is Event.OnSavedUsernameLoaded -> { state -> state.copy(savedUsername = event.username) From 078219d1472a0a042e95c21d84e05c08e14bb7a7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 28 Aug 2026 15:43:21 -0400 Subject: [PATCH 2/2] fix(profile): check username length before asking to confirm A one-character handle got the "Change Username?" confirmation and was only then told it was too short. The length check ran inside the CheckUsername pipeline, downstream of the gate. It now runs in the gate, ahead of both the confirmation and the first-claim path, and CheckUsername is left as the commit. This matches iOS, whose submit() returns on UsernameValidator.failure(for:) before it reaches setUsername, and matches the minimum-tip gate, which already validated first. Only the local rules move. Taken, moderated, reserved, and insufficient balance all come back from setUsername itself, which is the claim as well as the check, so there is nothing to pre-check without claiming the handle. --- .../username/UsernameEntryViewModel.kt | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt index e5622396d..69636c388 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt @@ -73,8 +73,9 @@ class UsernameEntryViewModel @Inject constructor( sealed interface Event { /** - * Save was pressed. Changing a claimed handle asks first — the old one is released and - * anyone can take it — while a first claim goes straight to [CheckUsername]. + * Save was pressed. Checks the length, then, when a handle is already claimed, asks + * before replacing it — the old one is released and anyone can take it. A first claim + * that clears the length check goes straight to [CheckUsername]. */ data object ConfirmUsernameChange : Event @@ -116,6 +117,17 @@ class UsernameEntryViewModel @Inject constructor( eventFlow .filterIsInstance() .onEach { + // Length is checked here rather than on the way to the server, so an input that + // could never be claimed says why instead of asking the user to confirm claiming + // it. Mirrors iOS, whose `submit()` bails on `UsernameValidator.failure(for:)` + // before it calls `setUsername`. The charset is already held by the field's input + // transformation, so length is all that is left to check locally. + lengthComplaint(stateFlow.value.usernameFieldState.text.toString()) + ?.let { complaint -> + handleUsernameSetFailure(complaint) + return@onEach + } + if (stateFlow.value.savedUsername.isBlank()) { dispatchEvent(Event.CheckUsername) return@onEach @@ -143,11 +155,7 @@ class UsernameEntryViewModel @Inject constructor( .filterIsInstance() .onEach { dispatchEvent(Event.UpdateProcessingState(loading = true)) } .map { stateFlow.value.usernameFieldState.text.toString() } - .map { username -> - lengthComplaint(username) - ?.let { Result.failure(it) } - ?: profileController.setUsername(username) - } + .map { username -> profileController.setUsername(username) } .onResult( onSuccess = { viewModelScope.launch {