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)