Skip to content

Commit 91be8b7

Browse files
authored
feat(user-profile): gate Save on a real edit and discard it on back (#1350)
* feat(user-profile): gate Save on a real edit and discard it on back The name and username steps seeded their field from the stored profile and then enabled the confirm button on any non-blank text, so opening either one armed Save before the user had changed anything — a tap that submitted the value that was already there. Both now keep the loaded value as a baseline in state and compare the field against it, so one character's difference is what arms the button and typing the original back in disarms it. Nodes 9553:113166 and 9553:113168. The seeding flows also lacked distinctUntilChanged, so any unrelated emission from the profile re-seeded the field and overwrote in-progress typing. Leaving a step throws the edit away. The back icon dispatches DiscardChanges, which puts the baseline back in the field; a gesture back reaches the same outcome because each step's ViewModel is scoped to its nav entry and goes with the pop. The photo step needed more than that: an abandoned pick left a re-encoded file in the cache with nothing to delete it, so onCleared drops it, covering the gesture back and the successful upload alike. * feat(user-profile): open the photo step on the current avatar The Set Profile Picture step opened on an empty well with a "+", so an account that already has a picture looked like it had none. It now seeds from the stored profile picture and falls back to it when a pick is discarded. The seeded value is a server-side MediaItem, never a local Uri, so it cannot be mistaken for a pick: Save stays disabled until one is made. That gate moves into State.isChanged, matching the name and username steps. Also stops the name and username fields being clobbered mid-edit. Both seeded the field on every profile emission, and the 60s poll publishes UserProfile.Empty when it cannot find the server profile — a different value, so distinctUntilChanged let it through and it overwrote whatever was being typed. The field now follows the store only while it is untouched; the baseline still moves either way, so the confirm button stays honest.
1 parent 1e23d5c commit 91be8b7

10 files changed

Lines changed: 471 additions & 18 deletions

File tree

‎apps/flipcash/features/user-profile/build.gradle.kts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies {
1616

1717
implementation(project(":apps:flipcash:shared:analytics"))
1818
implementation(project(":apps:flipcash:shared:blob"))
19+
implementation(project(":apps:flipcash:shared:common-ui"))
1920
implementation(project(":apps:flipcash:shared:featureflags"))
2021
implementation(project(":apps:flipcash:shared:userflags"))
2122

‎apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ internal fun NameEntryScreen(
6262
AppBarWithTitle(
6363
onBackIconClicked = {
6464
keyboard.hideIfVisible {
65+
// Leaving throws the edit away rather than carrying it back in. A gesture
66+
// back lands in the same place by a different route: the step's ViewModel
67+
// is scoped to its nav entry, so popping the entry drops the field with it.
68+
viewModel.dispatchEvent(NameEntryViewModel.Event.DiscardChanges)
6569
flowNavigator.back()
6670
}
6771
},
@@ -113,7 +117,7 @@ private fun NameEntryScreenContent(
113117
bottom = CodeTheme.dimens.grid.x3
114118
).imePadding(),
115119
text = stringResource(R.string.action_next),
116-
enabled = state.hasName && state.processingState.isIdle,
120+
enabled = state.hasName && state.isChanged && state.processingState.isIdle,
117121
isLoading = state.processingState.loading,
118122
isSuccess = state.processingState.success,
119123
onClick = {

‎apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt‎

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import com.getcode.view.BaseViewModel
2121
import com.getcode.view.LoadingSuccessState
2222
import dagger.hilt.android.lifecycle.HiltViewModel
2323
import kotlinx.coroutines.delay
24+
import kotlinx.coroutines.flow.distinctUntilChanged
2425
import kotlinx.coroutines.flow.filterIsInstance
2526
import kotlinx.coroutines.flow.launchIn
2627
import kotlinx.coroutines.flow.map
@@ -43,11 +44,20 @@ class NameEntryViewModel @Inject constructor(
4344
) {
4445
data class State(
4546
val nameFieldState: TextFieldState = TextFieldState(),
47+
/**
48+
* The stored display name: what the field is seeded with, what an edit is measured
49+
* against, and what a discarded edit reverts to.
50+
*/
51+
val savedName: String = "",
4652
val attestation: ModerationResult.Attestation = ModerationResult.Attestation.Empty,
4753
val processingState: LoadingSuccessState = LoadingSuccessState(),
4854
) {
4955
val hasName: Boolean
5056
get() = nameFieldState.text.isNotBlank()
57+
58+
/** Node 9553:113166 — one character's difference is enough to arm the confirm button. */
59+
val isChanged: Boolean
60+
get() = nameFieldState.text.toString() != savedName
5161
}
5262

5363
sealed interface Event {
@@ -57,16 +67,40 @@ class NameEntryViewModel @Inject constructor(
5767
val success: Boolean = false
5868
) : Event
5969

70+
/** The stored name arrived (or changed) — the field and the baseline follow it. */
71+
data class OnSavedNameLoaded(val name: String) : Event
72+
73+
/** Back was pressed with an uncommitted edit: put [State.savedName] back in the field. */
74+
data object DiscardChanges : Event
75+
6076
data object OnNameApproved : Event
6177
}
6278

6379
init {
6480
userManager.state
6581
.mapNotNull { it.userProfile }
66-
.map { profile -> profile.displayName }
82+
.map { profile -> profile.displayName.orEmpty() }
83+
// Without this, any unrelated emission from the profile re-seeds the field and
84+
// overwrites whatever the user is part-way through typing.
85+
.distinctUntilChanged()
6786
.onEach { name ->
68-
val inputState = stateFlow.value.nameFieldState
69-
inputState.setTextAndPlaceCursorAtEnd(name.orEmpty())
87+
// distinctUntilChanged only stops an identical value from landing again; the
88+
// profile is polled every 60s and a refresh that can't find the server profile
89+
// publishes UserProfile.Empty, so a *different* name can still arrive mid-edit.
90+
// The field follows the store only while it is untouched — an edit owns it, and
91+
// the baseline moves under it so the confirm button stays honest either way.
92+
val pristine = !stateFlow.value.isChanged
93+
dispatchEvent(Event.OnSavedNameLoaded(name))
94+
if (pristine) {
95+
stateFlow.value.nameFieldState.setTextAndPlaceCursorAtEnd(name)
96+
}
97+
}.launchIn(viewModelScope)
98+
99+
eventFlow
100+
.filterIsInstance<Event.DiscardChanges>()
101+
.onEach {
102+
val state = stateFlow.value
103+
state.nameFieldState.setTextAndPlaceCursorAtEnd(state.savedName)
70104
}.launchIn(viewModelScope)
71105

72106
eventFlow
@@ -157,10 +191,12 @@ class NameEntryViewModel @Inject constructor(
157191
}
158192
}
159193
}
160-
companion object {
161-
private val updateStateForEvent: (Event) -> (State.() -> State) = { event ->
194+
internal companion object {
195+
val updateStateForEvent: (Event) -> (State.() -> State) = { event ->
162196
when (event) {
163197
is Event.CheckName -> { state -> state }
198+
is Event.OnSavedNameLoaded -> { state -> state.copy(savedName = event.name) }
199+
Event.DiscardChanges -> { state -> state }
164200
is Event.UpdateProcessingState -> { state ->
165201
val current = state.processingState
166202
state.copy(

‎apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionScreen.kt‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import com.flipcash.app.core.ui.transitions.sharedBoundsTransition
4141
import com.flipcash.app.core.userprofile.UpdateProfileResult
4242
import com.flipcash.app.core.userprofile.UpdateProfileStep
4343
import com.flipcash.core.R
44+
import com.flipcash.shared.common.ui.ContactAvatar
4445
import com.getcode.navigation.flow.rememberFlowNavigator
4546
import com.getcode.theme.CodeTheme
4647
import com.getcode.theme.White50
@@ -70,6 +71,9 @@ internal fun PhotoSelectionScreen() {
7071
titleAlignment = Alignment.CenterHorizontally,
7172
onBackIconClicked = {
7273
keyboard.hideIfVisible {
74+
// Leaving throws the pick away rather than carrying it back in; the stored
75+
// picture is whatever it was before the step opened.
76+
viewModel.dispatchEvent(PhotoSelectionViewModel.Event.DiscardChanges)
7377
flowNavigator.back()
7478
}
7579
},
@@ -116,7 +120,7 @@ private fun PhotoSelectionScreenContent(
116120
.navigationBarsPadding()
117121
.padding(bottom = CodeTheme.dimens.grid.x3),
118122
text = stringResource(R.string.action_save),
119-
enabled = state.image.isLoaded() && state.processingState.isIdle,
123+
enabled = state.isChanged && state.processingState.isIdle,
120124
isLoading = state.processingState.loading,
121125
isSuccess = state.processingState.success,
122126
onClick = {
@@ -171,6 +175,17 @@ private fun PhotoSelectionScreenContent(
171175
CodeCircularProgressIndicator()
172176
}
173177
}
178+
// No pick pending: show whatever picture is already stored, so the
179+
// step opens on the current avatar rather than an empty well. It is a
180+
// server-side MediaItem, so it can't be mistaken for a pick — Save
181+
// stays disabled until one is made.
182+
state.savedPicture != null -> {
183+
ContactAvatar(
184+
image = state.savedPicture,
185+
displayName = state.name,
186+
modifier = Modifier.fillMaxSize(),
187+
)
188+
}
174189
else -> {
175190
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
176191
Icon(

‎apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModel.kt‎

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import androidx.annotation.StringRes
55
import androidx.lifecycle.viewModelScope
66
import com.flipcash.app.blob.BlobStorageCoordinator
77
import com.flipcash.app.core.data.Loadable
8+
import com.flipcash.app.core.data.isLoaded
89
import com.flipcash.app.core.extensions.flatMapResult
910
import com.flipcash.app.core.extensions.onResult
1011
import com.flipcash.services.models.blob.ImageConstraints
@@ -17,6 +18,7 @@ import com.flipcash.services.models.BlobRejectedException
1718
import com.flipcash.services.models.ImageModerationError
1819
import com.flipcash.services.models.ModerationResult
1920
import com.flipcash.services.models.TextModerationError
21+
import com.flipcash.services.models.chat.MediaItem
2022
import com.flipcash.services.models.chat.RejectionReason
2123
import com.flipcash.services.user.UserManager
2224
import com.getcode.manager.BottomBarManager
@@ -28,6 +30,7 @@ import com.getcode.view.BaseViewModel
2830
import com.getcode.view.LoadingSuccessState
2931
import dagger.hilt.android.lifecycle.HiltViewModel
3032
import kotlinx.coroutines.delay
33+
import kotlinx.coroutines.flow.distinctUntilChanged
3134
import kotlinx.coroutines.flow.filterIsInstance
3235
import kotlinx.coroutines.flow.filterNotNull
3336
import kotlinx.coroutines.flow.flatMapLatest
@@ -52,36 +55,67 @@ class PhotoSelectionViewModel @Inject constructor(
5255
private val resources: ResourceHelper,
5356
val contentReader: ContentReader,
5457
) : BaseViewModel<PhotoSelectionViewModel.State, PhotoSelectionViewModel.Event>(
55-
initialState = State(name = userManager.profile?.displayName.orEmpty()),
58+
initialState = State(
59+
name = userManager.profile?.displayName.orEmpty(),
60+
savedPicture = userManager.profile?.profilePicture,
61+
),
5662
updateStateForEvent = updateStateForEvent,
5763
defaultDispatcher = dispatchers.Default,
5864
) {
5965
data class State(
6066
val name: String,
67+
/**
68+
* The stored profile picture, shown until a pick replaces it and again if that pick is
69+
* discarded. Display only: it is a server-side [MediaItem], never a local [Uri], so it
70+
* can't arm Save — [image] holding a pick is still the only thing that counts as a change.
71+
*/
72+
val savedPicture: MediaItem? = null,
6173
val image: Loadable<Uri> = Loadable.Loading(),
6274
val attestation: ModerationResult.Attestation = ModerationResult.Attestation.Empty,
6375
val processingState: LoadingSuccessState = LoadingSuccessState(),
6476
// Server upload constraints (accepted MIME types + size ceilings), used to filter selection.
6577
val uploadPolicy: UploadPolicy? = null,
6678
// MIME type of the re-encoded image bytes to upload; resolved from the selected image.
6779
val imageMimeType: String = uploadMimeFor(null),
68-
)
80+
) {
81+
/**
82+
* The image case of nodes 9553:113166 / 9553:113168: a pick is the only thing that arms
83+
* Save. [savedPicture] is display only, so opening the step on the stored avatar leaves
84+
* this false.
85+
*/
86+
val isChanged: Boolean
87+
get() = image.isLoaded()
88+
}
6989

7090
sealed interface Event {
7191
data object CheckImage : Event
92+
93+
/** The stored picture arrived, or changed — including to null when it is unset. */
94+
data class OnSavedPictureLoaded(val picture: MediaItem?) : Event
7295
data class UploadPolicyLoaded(val policy: UploadPolicy) : Event
7396
data class UpdateProcessingState(
7497
val loading: Boolean = false,
7598
val success: Boolean = false
7699
) : Event
77100

78101
data object OnImageApproved : Event
102+
103+
/** Back was pressed with an unsaved pick: drop it, leaving the stored picture as it was. */
104+
data object DiscardChanges : Event
79105
data class OnImageSelected(val image: Uri) : Event
80106
data class OnImageCached(val image: Uri, val mimeType: String) : Event
81107
data object OnImageCleared : Event
82108
}
83109

84110
init {
111+
// Keeps the seeded avatar current: a save merges the server's renditions back into the
112+
// profile, so this is also what swaps the stored picture in once an upload lands.
113+
userManager.state
114+
.map { it.userProfile?.profilePicture }
115+
.distinctUntilChanged()
116+
.onEach { dispatchEvent(Event.OnSavedPictureLoaded(it)) }
117+
.launchIn(viewModelScope)
118+
85119
// Observe the policy — the coordinator serves the launch-preloaded cache and self-refreshes
86120
// it if it has aged past its ttl, re-emitting the fresh value here.
87121
blobStorage.policy
@@ -158,12 +192,32 @@ class PhotoSelectionViewModel @Inject constructor(
158192
},
159193
onError = { cause ->
160194
dispatchEvent(Event.UpdateProcessingState())
161-
stateFlow.value.image.dataOrNull?.let { contentReader.removeFromCache(it) }
162-
dispatchEvent(Event.OnImageCleared)
195+
discardPendingImage()
163196
handleUploadFailure(cause)
164197
}
165198
)
166199
.launchIn(viewModelScope)
200+
201+
eventFlow
202+
.filterIsInstance<Event.DiscardChanges>()
203+
.onEach { discardPendingImage() }
204+
.launchIn(viewModelScope)
205+
}
206+
207+
/**
208+
* A pick that never reached the server is only a re-encoded file in the cache, so leaving the
209+
* step has to delete it — nothing else ever will. Covers the paths the back button doesn't:
210+
* a gesture back, and the successful upload that makes the local copy redundant.
211+
*/
212+
override fun onCleared() {
213+
discardPendingImage()
214+
super.onCleared()
215+
}
216+
217+
private fun discardPendingImage() {
218+
val pending = stateFlow.value.image.dataOrNull ?: return
219+
contentReader.removeFromCache(pending)
220+
dispatchEvent(Event.OnImageCleared)
167221
}
168222

169223
/** Clears the pending selection and surfaces [title]/[message] to the user. */
@@ -349,9 +403,12 @@ class PhotoSelectionViewModel @Inject constructor(
349403
// Under-shoot the estimated fitting edge so re-encode overhead doesn't push us back over.
350404
private const val RESIZE_SAFETY = 0.9
351405

352-
private val updateStateForEvent: (Event) -> (State.() -> State) = { event ->
406+
internal val updateStateForEvent: (Event) -> (State.() -> State) = { event ->
353407
when (event) {
354408
Event.CheckImage -> { state -> state }
409+
is Event.OnSavedPictureLoaded -> { state ->
410+
state.copy(savedPicture = event.picture)
411+
}
355412
is Event.UploadPolicyLoaded -> { state -> state.copy(uploadPolicy = event.policy) }
356413
is Event.UpdateProcessingState -> { state ->
357414
val current = state.processingState
@@ -364,6 +421,7 @@ class PhotoSelectionViewModel @Inject constructor(
364421
}
365422

366423
Event.OnImageApproved -> { state -> state }
424+
Event.DiscardChanges -> { state -> state }
367425
is Event.OnImageCached -> { state ->
368426
state.copy(image = Loadable.Loaded(event.image), imageMimeType = event.mimeType)
369427
}

‎apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryScreen.kt‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ internal fun UsernameEntryScreen() {
5858
AppBarWithTitle(
5959
onBackIconClicked = {
6060
keyboard.hideIfVisible {
61+
// Leaving throws the edit away rather than carrying it back in. A gesture back
62+
// lands in the same place by a different route: the step's ViewModel is scoped
63+
// to its nav entry, so popping the entry drops the field with it.
64+
viewModel.dispatchEvent(UsernameEntryViewModel.Event.DiscardChanges)
6165
flowNavigator.back()
6266
}
6367
},
@@ -105,7 +109,7 @@ private fun UsernameEntryScreenContent(
105109
bottom = CodeTheme.dimens.grid.x3
106110
).imePadding(),
107111
text = stringResource(R.string.action_next),
108-
enabled = state.hasUsername && state.processingState.isIdle,
112+
enabled = state.hasUsername && state.isChanged && state.processingState.isIdle,
109113
isLoading = state.processingState.loading,
110114
isSuccess = state.processingState.success,
111115
onClick = {

0 commit comments

Comments
 (0)