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
1 change: 1 addition & 0 deletions apps/flipcash/features/user-profile/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies {

implementation(project(":apps:flipcash:shared:analytics"))
implementation(project(":apps:flipcash:shared:blob"))
implementation(project(":apps:flipcash:shared:common-ui"))
implementation(project(":apps:flipcash:shared:featureflags"))
implementation(project(":apps:flipcash:shared:userflags"))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ internal fun NameEntryScreen(
AppBarWithTitle(
onBackIconClicked = {
keyboard.hideIfVisible {
// Leaving throws the edit away rather than carrying it back in. A gesture
// back lands in the same place by a different route: the step's ViewModel
// is scoped to its nav entry, so popping the entry drops the field with it.
viewModel.dispatchEvent(NameEntryViewModel.Event.DiscardChanges)
flowNavigator.back()
}
},
Expand Down Expand Up @@ -113,7 +117,7 @@ private fun NameEntryScreenContent(
bottom = CodeTheme.dimens.grid.x3
).imePadding(),
text = stringResource(R.string.action_next),
enabled = state.hasName && state.processingState.isIdle,
enabled = state.hasName && state.isChanged && state.processingState.isIdle,
isLoading = state.processingState.loading,
isSuccess = state.processingState.success,
onClick = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import com.getcode.view.BaseViewModel
import com.getcode.view.LoadingSuccessState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
Expand All @@ -43,11 +44,20 @@ class NameEntryViewModel @Inject constructor(
) {
data class State(
val nameFieldState: TextFieldState = TextFieldState(),
/**
* The stored display name: what the field is seeded with, what an edit is measured
* against, and what a discarded edit reverts to.
*/
val savedName: String = "",
val attestation: ModerationResult.Attestation = ModerationResult.Attestation.Empty,
val processingState: LoadingSuccessState = LoadingSuccessState(),
) {
val hasName: Boolean
get() = nameFieldState.text.isNotBlank()

/** Node 9553:113166 — one character's difference is enough to arm the confirm button. */
val isChanged: Boolean
get() = nameFieldState.text.toString() != savedName
}

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

/** The stored name arrived (or changed) — the field and the baseline follow it. */
data class OnSavedNameLoaded(val name: String) : Event

/** Back was pressed with an uncommitted edit: put [State.savedName] back in the field. */
data object DiscardChanges : Event

data object OnNameApproved : Event
}

init {
userManager.state
.mapNotNull { it.userProfile }
.map { profile -> profile.displayName }
.map { profile -> profile.displayName.orEmpty() }
// Without this, any unrelated emission from the profile re-seeds the field and
// overwrites whatever the user is part-way through typing.
.distinctUntilChanged()
.onEach { name ->
val inputState = stateFlow.value.nameFieldState
inputState.setTextAndPlaceCursorAtEnd(name.orEmpty())
// distinctUntilChanged only stops an identical value from landing again; the
// profile is polled every 60s and a refresh that can't find the server profile
// publishes UserProfile.Empty, so a *different* name can still arrive mid-edit.
// The field follows the store only while it is untouched — an edit owns it, and
// the baseline moves under it so the confirm button stays honest either way.
val pristine = !stateFlow.value.isChanged
dispatchEvent(Event.OnSavedNameLoaded(name))
if (pristine) {
stateFlow.value.nameFieldState.setTextAndPlaceCursorAtEnd(name)
}
}.launchIn(viewModelScope)

eventFlow
.filterIsInstance<Event.DiscardChanges>()
.onEach {
val state = stateFlow.value
state.nameFieldState.setTextAndPlaceCursorAtEnd(state.savedName)
}.launchIn(viewModelScope)

eventFlow
Expand Down Expand Up @@ -157,10 +191,12 @@ class NameEntryViewModel @Inject constructor(
}
}
}
companion object {
private val updateStateForEvent: (Event) -> (State.() -> State) = { event ->
internal companion object {
val updateStateForEvent: (Event) -> (State.() -> State) = { event ->
when (event) {
is Event.CheckName -> { state -> state }
is Event.OnSavedNameLoaded -> { state -> state.copy(savedName = event.name) }
Event.DiscardChanges -> { state -> state }
is Event.UpdateProcessingState -> { state ->
val current = state.processingState
state.copy(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import com.flipcash.app.core.ui.transitions.sharedBoundsTransition
import com.flipcash.app.core.userprofile.UpdateProfileResult
import com.flipcash.app.core.userprofile.UpdateProfileStep
import com.flipcash.core.R
import com.flipcash.shared.common.ui.ContactAvatar
import com.getcode.navigation.flow.rememberFlowNavigator
import com.getcode.theme.CodeTheme
import com.getcode.theme.White50
Expand Down Expand Up @@ -70,6 +71,9 @@ internal fun PhotoSelectionScreen() {
titleAlignment = Alignment.CenterHorizontally,
onBackIconClicked = {
keyboard.hideIfVisible {
// Leaving throws the pick away rather than carrying it back in; the stored
// picture is whatever it was before the step opened.
viewModel.dispatchEvent(PhotoSelectionViewModel.Event.DiscardChanges)
flowNavigator.back()
}
},
Expand Down Expand Up @@ -116,7 +120,7 @@ private fun PhotoSelectionScreenContent(
.navigationBarsPadding()
.padding(bottom = CodeTheme.dimens.grid.x3),
text = stringResource(R.string.action_save),
enabled = state.image.isLoaded() && state.processingState.isIdle,
enabled = state.isChanged && state.processingState.isIdle,
isLoading = state.processingState.loading,
isSuccess = state.processingState.success,
onClick = {
Expand Down Expand Up @@ -171,6 +175,17 @@ private fun PhotoSelectionScreenContent(
CodeCircularProgressIndicator()
}
}
// No pick pending: show whatever picture is already stored, so the
// step opens on the current avatar rather than an empty well. It is a
// server-side MediaItem, so it can't be mistaken for a pick — Save
// stays disabled until one is made.
state.savedPicture != null -> {
ContactAvatar(
image = state.savedPicture,
displayName = state.name,
modifier = Modifier.fillMaxSize(),
)
}
else -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.annotation.StringRes
import androidx.lifecycle.viewModelScope
import com.flipcash.app.blob.BlobStorageCoordinator
import com.flipcash.app.core.data.Loadable
import com.flipcash.app.core.data.isLoaded
import com.flipcash.app.core.extensions.flatMapResult
import com.flipcash.app.core.extensions.onResult
import com.flipcash.services.models.blob.ImageConstraints
Expand All @@ -17,6 +18,7 @@ import com.flipcash.services.models.BlobRejectedException
import com.flipcash.services.models.ImageModerationError
import com.flipcash.services.models.ModerationResult
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.BottomBarManager
Expand All @@ -28,6 +30,7 @@ import com.getcode.view.BaseViewModel
import com.getcode.view.LoadingSuccessState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
Expand All @@ -52,36 +55,67 @@ class PhotoSelectionViewModel @Inject constructor(
private val resources: ResourceHelper,
val contentReader: ContentReader,
) : BaseViewModel<PhotoSelectionViewModel.State, PhotoSelectionViewModel.Event>(
initialState = State(name = userManager.profile?.displayName.orEmpty()),
initialState = State(
name = userManager.profile?.displayName.orEmpty(),
savedPicture = userManager.profile?.profilePicture,
),
updateStateForEvent = updateStateForEvent,
defaultDispatcher = dispatchers.Default,
) {
data class State(
val name: String,
/**
* The stored profile picture, shown until a pick replaces it and again if that pick is
* discarded. Display only: it is a server-side [MediaItem], never a local [Uri], so it
* can't arm Save — [image] holding a pick is still the only thing that counts as a change.
*/
val savedPicture: MediaItem? = null,
val image: Loadable<Uri> = Loadable.Loading(),
val attestation: ModerationResult.Attestation = ModerationResult.Attestation.Empty,
val processingState: LoadingSuccessState = LoadingSuccessState(),
// Server upload constraints (accepted MIME types + size ceilings), used to filter selection.
val uploadPolicy: UploadPolicy? = null,
// MIME type of the re-encoded image bytes to upload; resolved from the selected image.
val imageMimeType: String = uploadMimeFor(null),
)
) {
/**
* The image case of nodes 9553:113166 / 9553:113168: a pick is the only thing that arms
* Save. [savedPicture] is display only, so opening the step on the stored avatar leaves
* this false.
*/
val isChanged: Boolean
get() = image.isLoaded()
}

sealed interface Event {
data object CheckImage : Event

/** The stored picture arrived, or changed — including to null when it is unset. */
data class OnSavedPictureLoaded(val picture: MediaItem?) : Event
data class UploadPolicyLoaded(val policy: UploadPolicy) : Event
data class UpdateProcessingState(
val loading: Boolean = false,
val success: Boolean = false
) : Event

data object OnImageApproved : Event

/** Back was pressed with an unsaved pick: drop it, leaving the stored picture as it was. */
data object DiscardChanges : Event
data class OnImageSelected(val image: Uri) : Event
data class OnImageCached(val image: Uri, val mimeType: String) : Event
data object OnImageCleared : Event
}

init {
// Keeps the seeded avatar current: a save merges the server's renditions back into the
// profile, so this is also what swaps the stored picture in once an upload lands.
userManager.state
.map { it.userProfile?.profilePicture }
.distinctUntilChanged()
.onEach { dispatchEvent(Event.OnSavedPictureLoaded(it)) }
.launchIn(viewModelScope)

// Observe the policy — the coordinator serves the launch-preloaded cache and self-refreshes
// it if it has aged past its ttl, re-emitting the fresh value here.
blobStorage.policy
Expand Down Expand Up @@ -158,12 +192,32 @@ class PhotoSelectionViewModel @Inject constructor(
},
onError = { cause ->
dispatchEvent(Event.UpdateProcessingState())
stateFlow.value.image.dataOrNull?.let { contentReader.removeFromCache(it) }
dispatchEvent(Event.OnImageCleared)
discardPendingImage()
handleUploadFailure(cause)
}
)
.launchIn(viewModelScope)

eventFlow
.filterIsInstance<Event.DiscardChanges>()
.onEach { discardPendingImage() }
.launchIn(viewModelScope)
}

/**
* A pick that never reached the server is only a re-encoded file in the cache, so leaving the
* step has to delete it — nothing else ever will. Covers the paths the back button doesn't:
* a gesture back, and the successful upload that makes the local copy redundant.
*/
override fun onCleared() {
discardPendingImage()
super.onCleared()
}

private fun discardPendingImage() {
val pending = stateFlow.value.image.dataOrNull ?: return
contentReader.removeFromCache(pending)
dispatchEvent(Event.OnImageCleared)
}

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

private val updateStateForEvent: (Event) -> (State.() -> State) = { event ->
internal val updateStateForEvent: (Event) -> (State.() -> State) = { event ->
when (event) {
Event.CheckImage -> { state -> state }
is Event.OnSavedPictureLoaded -> { state ->
state.copy(savedPicture = event.picture)
}
is Event.UploadPolicyLoaded -> { state -> state.copy(uploadPolicy = event.policy) }
is Event.UpdateProcessingState -> { state ->
val current = state.processingState
Expand All @@ -364,6 +421,7 @@ class PhotoSelectionViewModel @Inject constructor(
}

Event.OnImageApproved -> { state -> state }
Event.DiscardChanges -> { state -> state }
is Event.OnImageCached -> { state ->
state.copy(image = Loadable.Loaded(event.image), imageMimeType = event.mimeType)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ internal fun UsernameEntryScreen() {
AppBarWithTitle(
onBackIconClicked = {
keyboard.hideIfVisible {
// Leaving throws the edit away rather than carrying it back in. A gesture back
// lands in the same place by a different route: the step's ViewModel is scoped
// to its nav entry, so popping the entry drops the field with it.
viewModel.dispatchEvent(UsernameEntryViewModel.Event.DiscardChanges)
flowNavigator.back()
}
},
Expand Down Expand Up @@ -105,7 +109,7 @@ private fun UsernameEntryScreenContent(
bottom = CodeTheme.dimens.grid.x3
).imePadding(),
text = stringResource(R.string.action_next),
enabled = state.hasUsername && state.processingState.isIdle,
enabled = state.hasUsername && state.isChanged && state.processingState.isIdle,
isLoading = state.processingState.loading,
isSuccess = state.processingState.success,
onClick = {
Expand Down
Loading
Loading