Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -964,6 +964,23 @@
<string name="error_title_usernameCheckFailed">Something Went Wrong</string>
<string name="error_description_usernameCheckFailed">Please try again</string>

<!-- Confirmation before a profile edit is committed. Only shown on a change: a first display
name, username, picture, or minimum tip overwrites nothing, so there is nothing to warn
about. Username carries the extra sentence because the old handle is released on change
and someone else can claim it. -->
<string name="prompt_title_changeDisplayName">Change Display Name?</string>
<string name="prompt_description_changeDisplayName">Are you sure you want to permanently change your display name?</string>
<string name="action_changeDisplayName">Change Display Name</string>
<string name="prompt_title_changeUsername">Change Username?</string>
<string name="prompt_description_changeUsername">Are you sure you want to permanently change your username? You might not be able to get your old username back</string>
<string name="action_changeUsername">Change Username</string>
<string name="prompt_title_changeProfilePicture">Change Profile Picture?</string>
<string name="prompt_description_changeProfilePicture">Are you sure you want to permanently change your profile picture?</string>
<string name="action_changeProfilePicture">Change Profile Picture</string>
<string name="prompt_title_changeMinimumTip">Change Minimum Tip?</string>
<string name="prompt_description_changeMinimumTip">Are you sure you want to permanently change your minimum tip?</string>
<string name="action_changeMinimumTip">Change Minimum Tip</string>

<!-- A tapped `flipcash.com/{username}` link that resolves to nothing. -->
<string name="error_title_usernameNotFound">No Such Account</string>
<string name="error_description_usernameNotFound">Nobody has claimed \@%1$s</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<Event.CommitRequested>()
.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))
Expand Down Expand Up @@ -207,6 +238,7 @@ internal class MinimumTipEntryViewModel @Inject constructor(
)
}
is Event.ConfirmRequested -> { state -> state }
is Event.CommitRequested -> { state -> state }
is Event.Saved -> { state -> state }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ private fun NameEntryScreenContent(
isSuccess = state.processingState.success,
onClick = {
keyboard.hideIfVisible {
dispatchEvent(NameEntryViewModel.Event.CheckName(source))
dispatchEvent(NameEntryViewModel.Event.ConfirmNameChange(source))
}
},
)
Expand Down Expand Up @@ -150,7 +150,7 @@ private fun NameEntryScreenContent(
),
onKeyboardAction = {
keyboard.hideIfVisible {
dispatchEvent(NameEntryViewModel.Event.CheckName(source))
dispatchEvent(NameEntryViewModel.Event.ConfirmNameChange(source))
}
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -96,6 +103,25 @@ class NameEntryViewModel @Inject constructor(
}
}.launchIn(viewModelScope)

eventFlow
.filterIsInstance<Event.ConfirmNameChange>()
.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<Event.DiscardChanges>()
.onEach {
Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ private fun PhotoSelectionScreenContent(
isLoading = state.processingState.loading,
isSuccess = state.processingState.success,
onClick = {
dispatchEvent(PhotoSelectionViewModel.Event.CheckImage)
dispatchEvent(PhotoSelectionViewModel.Event.ConfirmImageChange)
},
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -124,6 +131,25 @@ class PhotoSelectionViewModel @Inject constructor(
.launchIn(viewModelScope)


eventFlow
.filterIsInstance<Event.ConfirmImageChange>()
.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<Event.OnImageSelected>()
.mapNotNull { event ->
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ private fun UsernameEntryScreenContent(
isSuccess = state.processingState.success,
onClick = {
keyboard.hideIfVisible {
dispatchEvent(UsernameEntryViewModel.Event.CheckUsername)
dispatchEvent(UsernameEntryViewModel.Event.ConfirmUsernameChange)
}
},
)
Expand All @@ -140,7 +140,7 @@ private fun UsernameEntryScreenContent(
),
onKeyboardAction = {
keyboard.hideIfVisible {
dispatchEvent(UsernameEntryViewModel.Event.CheckUsername)
dispatchEvent(UsernameEntryViewModel.Event.ConfirmUsernameChange)
}
},
inputTransformation = UsernameInputTransformation,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -106,6 +114,36 @@ class UsernameEntryViewModel @Inject constructor(
}
}.launchIn(viewModelScope)

eventFlow
.filterIsInstance<Event.ConfirmUsernameChange>()
.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<Event.DiscardChanges>()
.onEach {
Expand All @@ -117,11 +155,7 @@ class UsernameEntryViewModel @Inject constructor(
.filterIsInstance<Event.CheckUsername>()
.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 {
Expand Down Expand Up @@ -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)
Expand Down
Loading