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..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 @@ -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,13 @@ class UsernameEntryViewModel @Inject constructor( } sealed interface Event { + /** + * 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 + data object CheckUsername : Event data class UpdateProcessingState( val loading: Boolean = false, @@ -106,6 +114,36 @@ class UsernameEntryViewModel @Inject constructor( } }.launchIn(viewModelScope) + 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 + } + 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 { @@ -117,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 { @@ -225,6 +259,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)