From ce048f93dcd5914c332f545742aab024039befce Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 16:30:36 -0400 Subject: [PATCH 1/9] feat(user-profile): edit your bio --- .../app/core/userprofile/UpdateProfileStep.kt | 5 + .../core/src/main/res/values/strings.xml | 8 + .../userprofile/UserProfileSetupFlowScreen.kt | 4 + .../app/userprofile/internal/bio/BioDraft.kt | 27 ++++ .../userprofile/internal/bio/EditBioScreen.kt | 140 ++++++++++++++++ .../internal/bio/EditBioViewModel.kt | 139 ++++++++++++++++ .../userprofile/internal/bio/BioDraftTest.kt | 76 +++++++++ .../internal/bio/EditBioViewModelTest.kt | 150 ++++++++++++++++++ 8 files changed, 549 insertions(+) create mode 100644 apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/BioDraft.kt create mode 100644 apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioScreen.kt create mode 100644 apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioViewModel.kt create mode 100644 apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/bio/BioDraftTest.kt create mode 100644 apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioViewModelTest.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt index 6ff720c01c..c8962f8691 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt @@ -38,4 +38,9 @@ sealed interface UpdateProfileStep : FlowStep, Parcelable { @Parcelize @Serializable object MinimumTip : UpdateProfileStep + + /** The profile's free-text bio, up to 160 characters. */ + @Parcelize + @Serializable + object Bio : UpdateProfileStep } diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index bf130a126b..cead172210 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1356,6 +1356,14 @@ Display Name Profile Picture + + Bio + Add a bio + This bio isn\'t allowed. Try different wording + This bio isn\'t valid + Something Went Wrong + Please try again + Scan Chat diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt index e93fe9698d..e2a971f204 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt @@ -7,6 +7,7 @@ import androidx.navigation3.runtime.entryProvider import com.flipcash.app.core.AppRoute import com.flipcash.app.core.userprofile.UpdateProfileResult import com.flipcash.app.core.userprofile.UpdateProfileStep +import com.flipcash.app.userprofile.internal.bio.EditBioScreen import com.flipcash.app.userprofile.internal.mintip.MinimumTipEntryScreen import com.flipcash.app.userprofile.internal.name.NameEntryScreen import com.flipcash.app.userprofile.internal.photo.PhotoSelectionScreen @@ -68,6 +69,9 @@ private fun profileUpdateProvider( annotatedEntry { PhotoSelectionScreen() } + annotatedEntry { + EditBioScreen() + } annotatedEntry { MinimumTipEntryScreen(isLastStep = route.steps.lastOrNull() == UpdateProfileStep.MinimumTip) } diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/BioDraft.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/BioDraft.kt new file mode 100644 index 0000000000..a14cf31df2 --- /dev/null +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/BioDraft.kt @@ -0,0 +1,27 @@ +package com.flipcash.app.userprofile.internal.bio + +/** + * The bio being edited. [original] is the stored bio an edit is measured against; [error] is the + * server's last refusal of [text] and goes away with the next edit. + */ +internal data class BioDraft( + val original: String, + val text: String, + val error: BioError? = null, +) { + /** Counted in code points, which tracks the server's rune count better than `String.length`. */ + val remaining: Int get() = MAX - text.codePointCount(0, text.length) + + /** A blank draft over a non-blank original is saveable: it clears the bio. */ + val canSave: Boolean get() = remaining >= 0 && text.trim() != original.trim() + + fun edited(newText: String): BioDraft = copy(text = newText, error = null) + + fun failed(error: BioError): BioDraft = copy(error = error) + + companion object { + const val MAX = 160 + } +} + +internal enum class BioError { Moderated, Invalid } diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioScreen.kt new file mode 100644 index 0000000000..a6e48dd7d0 --- /dev/null +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioScreen.kt @@ -0,0 +1,140 @@ +package com.flipcash.app.userprofile.internal.bio + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.ui.DisplayTextInput +import com.flipcash.app.core.userprofile.UpdateProfileResult +import com.flipcash.app.core.userprofile.UpdateProfileStep +import com.flipcash.core.R +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.utils.rememberKeyboardController +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@Composable +internal fun EditBioScreen() { + val flowNavigator = rememberFlowNavigator() + + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + val keyboard = rememberKeyboardController() + + Column { + AppBarWithTitle( + title = stringResource(R.string.title_bio), + onBackIconClicked = { + keyboard.hideIfVisible { flowNavigator.back() } + }, + ) + EditBioScreenContent(state, viewModel::dispatchEvent) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.proceed() } + .launchIn(this) + } +} + +@Composable +private fun EditBioScreenContent( + state: EditBioViewModel.State, + dispatchEvent: (EditBioViewModel.Event) -> Unit, +) { + val keyboard = rememberKeyboardController() + val draft = state.draft + CodeScaffold( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + bottomBar = { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding( + top = CodeTheme.dimens.grid.x6, + bottom = CodeTheme.dimens.grid.x3, + ).imePadding(), + text = stringResource(R.string.action_save), + enabled = draft.canSave && state.processingState.isIdle, + isLoading = state.processingState.loading, + isSuccess = state.processingState.success, + onClick = { + keyboard.hideIfVisible { dispatchEvent(EditBioViewModel.Event.Save) } + }, + ) + } + ) { padding -> + val focusRequester = remember { FocusRequester() } + Column( + modifier = Modifier.padding(padding), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + DisplayTextInput( + state = state.fieldState, + placeholder = stringResource(R.string.placeholder_bio), + style = CodeTheme.typography.textMedium.copy(color = CodeTheme.colors.textMain), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + minLines = 3, + maxLines = 8, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + imeAction = ImeAction.Default, + ), + ) + + val error = draft.error + if (error != null) { + Text( + text = stringResource( + when (error) { + BioError.Moderated -> R.string.error_bioNotAllowed + BioError.Invalid -> R.string.error_bioInvalid + } + ), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.errorText, + ) + } + + Text( + modifier = Modifier.fillMaxWidth(), + text = draft.remaining.toString(), + style = CodeTheme.typography.textSmall, + color = if (draft.remaining < 0) CodeTheme.colors.errorText else CodeTheme.colors.textSecondary, + textAlign = TextAlign.End, + ) + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } +} diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioViewModel.kt new file mode 100644 index 0000000000..5fea3b044d --- /dev/null +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioViewModel.kt @@ -0,0 +1,139 @@ +package com.flipcash.app.userprofile.internal.bio + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.viewModelScope +import com.flipcash.features.userprofile.R +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.SetBioError +import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.model.core.errors.ValidationException +import com.getcode.util.resources.ResourceHelper +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 +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +@HiltViewModel +internal class EditBioViewModel @Inject constructor( + userManager: UserManager, + private val profileController: ProfileController, + private val resources: ResourceHelper, + dispatchers: DispatcherProvider, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, +) { + data class State( + val fieldState: TextFieldState = TextFieldState(), + val draft: BioDraft = BioDraft(original = "", text = ""), + val processingState: LoadingSuccessState = LoadingSuccessState(), + ) + + sealed interface Event { + data object Save : Event + data class OnSavedBioLoaded(val bio: String) : Event + data class OnTextChanged(val text: String) : Event + data class OnFailed(val error: BioError) : Event + data class UpdateProcessingState( + val loading: Boolean = false, + val success: Boolean = false, + ) : Event + + data object OnBioSaved : Event + } + + init { + userManager.state + .mapNotNull { it.userProfile } + .map { it.bio } + .distinctUntilChanged() + .onEach { bio -> + // The field follows the store only while untouched; an edit in progress owns it. + val pristine = stateFlow.value.draft.let { it.text == it.original } + dispatchEvent(Event.OnSavedBioLoaded(bio)) + if (pristine) { + stateFlow.value.fieldState.setTextAndPlaceCursorAtEnd(bio) + } + }.launchIn(viewModelScope) + + snapshotFlow { stateFlow.value.fieldState.text.toString() } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnTextChanged(it)) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + val draft = stateFlow.value.draft + if (!draft.canSave || !stateFlow.value.processingState.isIdle) return@onEach + dispatchEvent(Event.UpdateProcessingState(loading = true)) + profileController.setBio(draft.text.trim()) + .onSuccess { + viewModelScope.launch { + dispatchEvent(Event.UpdateProcessingState(success = true)) + delay(500.milliseconds) + dispatchEvent(Event.OnBioSaved) + dispatchEvent(Event.UpdateProcessingState()) + } + } + .onFailure { cause -> + dispatchEvent(Event.UpdateProcessingState()) + handleSaveFailure(cause) + } + }.launchIn(viewModelScope) + } + + // The text is kept in every case so the user can reword it rather than retype it. + private fun handleSaveFailure(cause: Throwable) { + when (cause) { + is SetBioError.FailedModerated -> dispatchEvent(Event.OnFailed(BioError.Moderated)) + is SetBioError.InvalidBio, + is ValidationException -> dispatchEvent(Event.OnFailed(BioError.Invalid)) + + else -> BottomBarManager.showError( + title = resources.getString(R.string.error_title_bioSaveFailed), + message = resources.getString(R.string.error_description_bioSaveFailed), + ) + } + } + + internal companion object { + val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + Event.Save -> { state -> state } + Event.OnBioSaved -> { state -> state } + is Event.OnSavedBioLoaded -> { state -> + state.copy(draft = state.draft.copy(original = event.bio)) + } + is Event.OnTextChanged -> { state -> + // The error described the previous text, so a real edit drops it. + if (state.draft.text == event.text) state + else state.copy(draft = state.draft.edited(event.text)) + } + is Event.OnFailed -> { state -> state.copy(draft = state.draft.failed(event.error)) } + is Event.UpdateProcessingState -> { state -> + state.copy( + processingState = state.processingState.copy( + loading = event.loading, + success = event.success, + ) + ) + } + } + } + } +} diff --git a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/bio/BioDraftTest.kt b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/bio/BioDraftTest.kt new file mode 100644 index 0000000000..d0de1037a0 --- /dev/null +++ b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/bio/BioDraftTest.kt @@ -0,0 +1,76 @@ +package com.flipcash.app.userprofile.internal.bio + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class BioDraftTest { + + @Test + fun `an untouched bio has nothing to save`() { + assertFalse(BioDraft(original = "Hello", text = "Hello").canSave) + } + + @Test + fun `whitespace around the same text is not a change`() { + assertFalse(BioDraft(original = "Hello", text = " Hello \n").canSave) + } + + @Test + fun `a different bio can be saved`() { + assertTrue(BioDraft(original = "Hello", text = "Hello there").canSave) + } + + @Test + fun `blank over a non-blank bio is saveable because it clears the bio`() { + assertTrue(BioDraft(original = "Hello", text = "").canSave) + assertTrue(BioDraft(original = "Hello", text = " ").canSave) + } + + @Test + fun `blank over a blank bio is not a change`() { + assertFalse(BioDraft(original = "", text = " ").canSave) + } + + @Test + fun `160 code points fit and 161 do not`() { + val fits = BioDraft(original = "", text = "a".repeat(160)) + val over = BioDraft(original = "", text = "a".repeat(161)) + + assertEquals(0, fits.remaining) + assertTrue(fits.canSave) + assertEquals(-1, over.remaining) + assertFalse(over.canSave) + } + + @Test + fun `an emoji counts once however many chars it takes`() { + val emoji = "😀" // one code point, two UTF-16 chars + val draft = BioDraft(original = "", text = emoji.repeat(160)) + + assertEquals(320, draft.text.length) + assertEquals(0, draft.remaining) + assertTrue(draft.canSave) + } + + @Test + fun `editing clears the error`() { + val failed = BioDraft(original = "", text = "bad", error = BioError.Moderated) + + val edited = failed.edited("better") + + assertEquals("better", edited.text) + assertNull(edited.error) + assertEquals("", edited.original) + } + + @Test + fun `a failure keeps the text and records the error`() { + val draft = BioDraft(original = "", text = "bad").failed(BioError.Invalid) + + assertEquals("bad", draft.text) + assertEquals(BioError.Invalid, draft.error) + } +} diff --git a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioViewModelTest.kt b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioViewModelTest.kt new file mode 100644 index 0000000000..3fb463d9b5 --- /dev/null +++ b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioViewModelTest.kt @@ -0,0 +1,150 @@ +package com.flipcash.app.userprofile.internal.bio + +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.ModerationResult +import com.flipcash.services.models.SetBioError +import com.flipcash.services.models.UserProfile +import com.flipcash.services.user.UserManager +import com.getcode.opencode.model.core.errors.ValidationException +import com.getcode.util.resources.FakeResourceHelper +import androidx.compose.runtime.snapshots.Snapshot +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class EditBioViewModelTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val userManager = mockk(relaxed = true) + private val profileController = mock() + + private fun profile(bio: String) = UserProfile.Empty.copy(bio = bio) + + private fun kotlinx.coroutines.test.TestScope.viewModel(stored: String = "Hello"): EditBioViewModel { + every { userManager.state } returns MutableStateFlow(UserManager.State(userProfile = profile(stored))) + return EditBioViewModel( + userManager = userManager, + profileController = profileController, + resources = FakeResourceHelper(), + dispatchers = TestDispatchers(testScheduler), + ) + } + + // snapshotFlow only sees a field edit once the global snapshot's apply notifications go out, + // which the Compose runtime does on a device and a plain JVM test has to do by hand. + private fun EditBioViewModel.type(edit: androidx.compose.foundation.text.input.TextFieldBuffer.() -> Unit) { + stateFlow.value.fieldState.edit(edit) + Snapshot.sendApplyNotifications() + } + + @Test + fun `opens on the stored bio with nothing to save`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel("Hello") + advanceUntilIdle() + + assertEquals("Hello", vm.stateFlow.value.fieldState.text.toString()) + assertEquals(false, vm.stateFlow.value.draft.canSave) + } + + @Test + fun `a failed moderation keeps the text and shows the moderated error`() = runTest(mainCoroutineRule.dispatcher) { + whenever(profileController.setBio(any())).thenReturn( + Result.failure(SetBioError.FailedModerated(ModerationResult.FlaggedCategory.OTHER)) + ) + val vm = viewModel("Hello") + advanceUntilIdle() + vm.type { replace(0, length, "Something rude") } + advanceUntilIdle() + + vm.dispatchEvent(EditBioViewModel.Event.Save) + advanceUntilIdle() + + assertEquals(BioError.Moderated, vm.stateFlow.value.draft.error) + assertEquals("Something rude", vm.stateFlow.value.draft.text) + assertEquals(true, vm.stateFlow.value.processingState.isIdle) + } + + @Test + fun `an invalid bio and a validation failure both show the invalid error`() = runTest(mainCoroutineRule.dispatcher) { + whenever(profileController.setBio(any())).thenReturn(Result.failure(SetBioError.InvalidBio())) + val vm = viewModel("Hello") + advanceUntilIdle() + vm.type { replace(0, length, "x") } + advanceUntilIdle() + vm.dispatchEvent(EditBioViewModel.Event.Save) + advanceUntilIdle() + assertEquals(BioError.Invalid, vm.stateFlow.value.draft.error) + + whenever(profileController.setBio(any())).thenReturn(Result.failure(ValidationException(emptyList()))) + vm.type { replace(0, length, "y") } + advanceUntilIdle() + assertNull(vm.stateFlow.value.draft.error) + vm.dispatchEvent(EditBioViewModel.Event.Save) + advanceUntilIdle() + assertEquals(BioError.Invalid, vm.stateFlow.value.draft.error) + } + + @Test + fun `editing after a failure clears the error`() = runTest(mainCoroutineRule.dispatcher) { + whenever(profileController.setBio(any())).thenReturn(Result.failure(SetBioError.InvalidBio())) + val vm = viewModel("Hello") + advanceUntilIdle() + vm.type { replace(0, length, "x") } + advanceUntilIdle() + vm.dispatchEvent(EditBioViewModel.Event.Save) + advanceUntilIdle() + + vm.type { append("y") } + advanceUntilIdle() + + assertNull(vm.stateFlow.value.draft.error) + } + + @Test + fun `an unchanged bio is never sent`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel("Hello") + advanceUntilIdle() + + vm.dispatchEvent(EditBioViewModel.Event.Save) + advanceUntilIdle() + + verify(profileController, never()).setBio(any()) + } + + @Test + fun `a saved bio is sent trimmed and the step completes`() = runTest(mainCoroutineRule.dispatcher) { + whenever(profileController.setBio(any())).thenReturn(Result.success(Unit)) + val vm = viewModel("Hello") + advanceUntilIdle() + vm.type { replace(0, length, " New bio \n") } + advanceUntilIdle() + + val saved = mutableListOf() + val collector = launch { vm.eventFlow.collect { saved += it } } + vm.dispatchEvent(EditBioViewModel.Event.Save) + advanceUntilIdle() + collector.cancel() + + verify(profileController).setBio("New bio") + assertEquals(true, saved.any { it is EditBioViewModel.Event.OnBioSaved }) + } +} From ed63b487df26b262d92f360a3a2cb2389ab31916 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 16:33:09 -0400 Subject: [PATCH 2/9] feat(user-profile): change your cover picture --- .../app/core/userprofile/UpdateProfileStep.kt | 5 + .../core/src/main/res/values/strings.xml | 6 + .../userprofile/UserProfileSetupFlowScreen.kt | 4 + .../internal/photo/PhotoSelectionScreen.kt | 64 +++++--- .../internal/photo/PhotoSelectionViewModel.kt | 69 +++++++- .../photo/PhotoSelectionViewModelTest.kt | 152 ++++++++++++++++++ .../shared/common/ui/profile/ProfileCover.kt | 14 +- 7 files changed, 287 insertions(+), 27 deletions(-) create mode 100644 apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModelTest.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt index c8962f8691..0526039f0b 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt @@ -31,6 +31,11 @@ sealed interface UpdateProfileStep : FlowStep, Parcelable { @Serializable object Photo : UpdateProfileStep + /** The banner at the top of the profile, picked and uploaded the way [Photo] is. */ + @Parcelize + @Serializable + object Cover : UpdateProfileStep + /** * The fee another user has to pay to open a DM, which the profile carries as * `minDmChatInitFee`. diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index cead172210..8afd45dd8f 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1252,6 +1252,12 @@ AI flagged this photo as misleading. Please try a different photo. If you think the photo was rejected in error please DM @flipcash on X AI flagged this photo as spam. Please try a different photo. If you think the photo was rejected in error please DM @flipcash on X Set Profile Picture + Set Cover + Change Cover? + This will change your cover + Change Cover + Couldn\'t Upload Your Cover + This image isn\'t allowed as a cover. Please try a different image. If you think it was rejected in error please DM @flipcash on X Your Name 500x500 Recommended My Tip Card diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt index e2a971f204..0aedc01b25 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt @@ -11,6 +11,7 @@ import com.flipcash.app.userprofile.internal.bio.EditBioScreen import com.flipcash.app.userprofile.internal.mintip.MinimumTipEntryScreen import com.flipcash.app.userprofile.internal.name.NameEntryScreen import com.flipcash.app.userprofile.internal.photo.PhotoSelectionScreen +import com.flipcash.app.userprofile.internal.photo.PhotoSelectionViewModel import com.flipcash.app.userprofile.internal.username.UsernameEntryScreen import com.getcode.navigation.annotatedEntry import com.getcode.navigation.core.LocalCodeNavigator @@ -69,6 +70,9 @@ private fun profileUpdateProvider( annotatedEntry { PhotoSelectionScreen() } + annotatedEntry { + PhotoSelectionScreen(slot = PhotoSelectionViewModel.Slot.Cover) + } annotatedEntry { EditBioScreen() } 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 acb76c7e37..ddba51eeaa 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 @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding @@ -28,6 +29,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -43,6 +45,8 @@ import com.flipcash.app.core.userprofile.UpdateProfileStep import com.flipcash.core.R import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.shared.common.ui.profile.ProfileCover +import com.flipcash.shared.common.ui.profile.ProfileCoverAspectRatio import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme import com.getcode.theme.White50 @@ -58,17 +62,21 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @Composable -internal fun PhotoSelectionScreen() { +internal fun PhotoSelectionScreen(slot: PhotoSelectionViewModel.Slot = PhotoSelectionViewModel.Slot.Avatar) { val flowNavigator = rememberFlowNavigator() - val viewModel = hiltViewModel() + val viewModel = hiltViewModel( + creationCallback = { factory -> factory.create(slot) }, + ) val state by viewModel.stateFlow.collectAsStateWithLifecycle() val keyboard = rememberKeyboardController() Column { AppBarWithTitle( - title = stringResource(R.string.title_setProfilePicture), + title = stringResource( + if (slot == PhotoSelectionViewModel.Slot.Cover) R.string.title_setCover else R.string.title_setProfilePicture + ), titleAlignment = Alignment.CenterHorizontally, onBackIconClicked = { keyboard.hideIfVisible { @@ -141,16 +149,26 @@ private fun PhotoSelectionScreenContent( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset), ) { + // The avatar is a circle that shares its bounds with the name step; the cover is + // previewed at the banner's own aspect so the crop matches what the profile shows. + val isCover = state.slot == PhotoSelectionViewModel.Slot.Cover + val pictureShape = if (isCover) CodeTheme.shapes.medium else CircleShape Box( - modifier = Modifier - .size(150.dp) - .sharedBoundsTransition( - transition = SharedTransition.CurrencyIcon, - ) + modifier = (if (isCover) { + Modifier + .fillMaxWidth() + .aspectRatio(ProfileCoverAspectRatio) + } else { + Modifier + .size(150.dp) + .sharedBoundsTransition( + transition = SharedTransition.CurrencyIcon, + ) + }) .background( color = CodeTheme.colors.divider, - shape = CircleShape, - ).clip(CircleShape) + shape = pictureShape, + ).clip(pictureShape) .clickable { pickMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) }, @@ -180,6 +198,14 @@ private fun PhotoSelectionScreenContent( // 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 && isCover -> { + ProfileCover( + image = state.savedPicture, + access = BlobAccessContext.Owned, + modifier = Modifier.fillMaxSize(), + height = Dp.Unspecified, + ) + } state.savedPicture != null -> { ContactAvatar( image = state.savedPicture, @@ -203,14 +229,16 @@ private fun PhotoSelectionScreenContent( } - Text( - modifier = Modifier.sharedBoundsTransition( - transition = SharedTransition.CurrencyName, - ), - text = state.name.ifBlank { stringResource(R.string.placeholder_profileDisplayName) }, - style = CodeTheme.typography.displaySmall, - color = Color.White, - ) + if (!isCover) { + Text( + modifier = Modifier.sharedBoundsTransition( + transition = SharedTransition.CurrencyName, + ), + text = state.name.ifBlank { stringResource(R.string.placeholder_profileDisplayName) }, + style = CodeTheme.typography.displaySmall, + color = Color.White, + ) + } } } } 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 f45841d306..1f03a1ca5c 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 @@ -17,7 +17,9 @@ import com.flipcash.services.controllers.ProfileController import com.flipcash.services.models.BlobRejectedException import com.flipcash.services.models.ImageModerationError import com.flipcash.services.models.ModerationResult +import com.flipcash.services.models.SetCoverPictureError import com.flipcash.services.models.TextModerationError +import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.models.chat.RejectionReason import com.flipcash.services.user.UserManager @@ -29,6 +31,9 @@ import com.getcode.util.resources.ResourceHelper import com.getcode.util.resources.uploadMimeFor import com.getcode.view.BaseViewModel import com.getcode.view.LoadingSuccessState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged @@ -44,8 +49,13 @@ import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds -@HiltViewModel -class PhotoSelectionViewModel @Inject constructor( +/** + * Backs one picture step of the profile editor. The [slot] says which picture the step edits, so a + * cover step seeds from, uploads to and reports on the cover only — never the avatar — and an avatar + * step the reverse. Each step gets its own instance. + */ +@HiltViewModel(assistedFactory = PhotoSelectionViewModel.Factory::class) +class PhotoSelectionViewModel @AssistedInject constructor( dispatchers: DispatcherProvider, userManager: UserManager, private val moderationController: ModerationController, @@ -54,16 +64,27 @@ class PhotoSelectionViewModel @Inject constructor( private val imagePreparer: ImageUploadPreparer, private val resources: ResourceHelper, val contentReader: ContentReader, + @Assisted val slot: Slot, ) : BaseViewModel( initialState = State( name = userManager.profile?.displayName.orEmpty(), - savedPicture = userManager.profile?.profilePicture, + savedPicture = userManager.profile?.pictureFor(slot), + slot = slot, ), updateStateForEvent = updateStateForEvent, defaultDispatcher = dispatchers.Default, ) { + /** Which of the profile's two pictures a step edits. */ + enum class Slot { Avatar, Cover } + + @AssistedFactory + interface Factory { + fun create(slot: Slot): PhotoSelectionViewModel + } + data class State( val name: String, + val slot: Slot = Slot.Avatar, /** * 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 @@ -117,7 +138,7 @@ class PhotoSelectionViewModel @Inject constructor( // 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 } + .map { it.userProfile?.pictureFor(slot) } .distinctUntilChanged() .onEach { dispatchEvent(Event.OnSavedPictureLoaded(it)) } .launchIn(viewModelScope) @@ -138,10 +159,18 @@ class PhotoSelectionViewModel @Inject constructor( return@onEach } BottomBarManager.showMessage( - title = resources.getString(R.string.prompt_title_changeProfilePicture), - message = resources.getString(R.string.prompt_description_changeProfilePicture), + title = resources.getString( + if (slot == Slot.Cover) R.string.prompt_title_changeCover else R.string.prompt_title_changeProfilePicture + ), + message = resources.getString( + if (slot == Slot.Cover) R.string.prompt_description_changeCover else R.string.prompt_description_changeProfilePicture + ), actions = listOf( - BottomBarAction(resources.getString(R.string.action_changeProfilePicture)) { + BottomBarAction( + resources.getString( + if (slot == Slot.Cover) R.string.action_changeCover else R.string.action_changeProfilePicture + ) + ) { dispatchEvent(Event.CheckImage) } ), @@ -195,7 +224,10 @@ class PhotoSelectionViewModel @Inject constructor( blobStorage.upload(bytes = bytes, mimeType = stateFlow.value.imageMimeType) } .flatMapResult { blobId -> - profileController.setProfilePicture(blobId) + when (slot) { + Slot.Avatar -> profileController.setProfilePicture(blobId) + Slot.Cover -> profileController.setCoverPicture(blobId) + } } .onResult( onSuccess = { _ -> @@ -246,6 +278,13 @@ class PhotoSelectionViewModel @Inject constructor( } private fun handleUploadFailure(cause: Throwable) { + if (slot == Slot.Cover && cause.isCoverRejection()) { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_coverNotAllowed), + message = resources.getString(R.string.error_description_coverNotAllowed), + ) + return + } when (cause) { is BlobRejectedException -> { when (cause.rejection.reason) { @@ -356,6 +395,20 @@ class PhotoSelectionViewModel @Inject constructor( companion object { + private fun UserProfile.pictureFor(slot: Slot): MediaItem? = when (slot) { + Slot.Avatar -> profilePicture + Slot.Cover -> coverPicture + } + + // Moderation and unclassified refusals are the ones a different image can fix; transport + // and format failures keep the generic wording. + private fun Throwable.isCoverRejection(): Boolean = when (this) { + is SetCoverPictureError.BlobRejected -> true + is BlobRejectedException -> rejection.reason == RejectionReason.MODERATION || + rejection.reason == RejectionReason.UNKNOWN + else -> false + } + internal val updateStateForEvent: (Event) -> (State.() -> State) = { event -> when (event) { Event.ConfirmImageChange -> { state -> state } diff --git a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModelTest.kt b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModelTest.kt new file mode 100644 index 0000000000..d60c408395 --- /dev/null +++ b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModelTest.kt @@ -0,0 +1,152 @@ +package com.flipcash.app.userprofile.internal.photo + +import android.net.Uri +import com.flipcash.app.blob.BlobStorageCoordinator +import com.flipcash.app.blob.ImageUploadPreparer +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.services.controllers.ModerationController +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.chat.MediaItemRendition +import com.flipcash.services.user.UserManager +import com.getcode.util.resources.ContentReader +import com.getcode.util.resources.FakeResourceHelper +import android.webkit.MimeTypeMap +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.test.assertEquals + +/** + * One view model serves each editor step, and the slot it was made for decides which picture it + * seeds from and which one it writes. A cover step must never touch the avatar, and an avatar step + * must never touch the cover. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class PhotoSelectionViewModelTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val avatar = MediaItem(renditions = emptyList()) + private val cover = MediaItem( + renditions = listOf(MediaItemRendition(MediaItemRendition.Role.DISPLAY, BlobId(ByteArray(16) { 1 }), blob = null)) + ) + private val blobId = BlobId(ByteArray(16)) + private val pick: Uri = mock() + + private val userManager = mockk(relaxed = true) + private val moderationController = mockk(relaxed = true) + private val profileController = mock() + private val blobStorage = mock() + private val imagePreparer = mockk(relaxed = true) + private val contentReader = mockk(relaxed = true) + + // The state's default MIME type reaches for Android's MimeTypeMap, which a JVM test doesn't have. + @Before + fun stubMimeTypeMap() { + val map = mockk() + every { map.getExtensionFromMimeType(any()) } returns "jpg" + every { map.getMimeTypeFromExtension(any()) } returns "image/jpeg" + mockkStatic(MimeTypeMap::class) + every { MimeTypeMap.getSingleton() } returns map + } + + @After + fun unstubMimeTypeMap() { + unmockkStatic(MimeTypeMap::class) + } + + private fun TestScope.viewModel(slot: PhotoSelectionViewModel.Slot): PhotoSelectionViewModel { + every { userManager.profile } returns UserProfile.Empty.copy( + profilePicture = avatar, + coverPicture = cover, + ) + every { userManager.state } returns MutableStateFlow( + UserManager.State(userProfile = UserProfile.Empty.copy(profilePicture = avatar, coverPicture = cover)) + ) + every { contentReader.readBytes(any()) } returns ByteArray(4) + whenever(blobStorage.policy).thenReturn(MutableStateFlow(null)) + return PhotoSelectionViewModel( + dispatchers = TestDispatchers(testScheduler), + userManager = userManager, + moderationController = moderationController, + profileController = profileController, + blobStorage = blobStorage, + imagePreparer = imagePreparer, + resources = FakeResourceHelper(), + contentReader = contentReader, + slot = slot, + ) + } + + private suspend fun stubUpload() { + whenever(blobStorage.upload(any(), any(), anyOrNull())).thenReturn(Result.success(blobId)) + } + + @Test + fun `an avatar step seeds from the profile picture`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel(PhotoSelectionViewModel.Slot.Avatar) + advanceUntilIdle() + + assertEquals(avatar, vm.stateFlow.value.savedPicture) + } + + @Test + fun `a cover step seeds from the cover picture`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel(PhotoSelectionViewModel.Slot.Cover) + advanceUntilIdle() + + assertEquals(cover, vm.stateFlow.value.savedPicture) + } + + @Test + fun `a cover step uploads to the cover and never the avatar`() = runTest(mainCoroutineRule.dispatcher) { + stubUpload() + whenever(profileController.setCoverPicture(any())).thenReturn(Result.success(cover)) + val vm = viewModel(PhotoSelectionViewModel.Slot.Cover) + advanceUntilIdle() + + vm.dispatchEvent(PhotoSelectionViewModel.Event.OnImageCached(pick, "image/jpeg")) + vm.dispatchEvent(PhotoSelectionViewModel.Event.CheckImage) + advanceUntilIdle() + + verify(profileController).setCoverPicture(blobId) + verify(profileController, never()).setProfilePicture(any()) + } + + @Test + fun `an avatar step uploads to the avatar and never the cover`() = runTest(mainCoroutineRule.dispatcher) { + stubUpload() + whenever(profileController.setProfilePicture(any())).thenReturn(Result.success(avatar)) + val vm = viewModel(PhotoSelectionViewModel.Slot.Avatar) + advanceUntilIdle() + + vm.dispatchEvent(PhotoSelectionViewModel.Event.OnImageCached(pick, "image/jpeg")) + vm.dispatchEvent(PhotoSelectionViewModel.Event.CheckImage) + advanceUntilIdle() + + verify(profileController).setProfilePicture(blobId) + verify(profileController, never()).setCoverPicture(any()) + } +} diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileCover.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileCover.kt index 7362398616..a4203fe53f 100644 --- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileCover.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileCover.kt @@ -7,7 +7,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.MediaItem @@ -16,6 +18,12 @@ import com.getcode.theme.CodeTheme internal val ProfileCoverHeight = 214.dp +/** + * Width over height of the cover as it sits on a profile (a 375dp screen by [ProfileCoverHeight]). + * Anything previewing a cover at another size keeps this ratio so it crops the way the profile will. + */ +const val ProfileCoverAspectRatio = 375f / 214f + /** * The full-bleed picture at the top of a profile. It is drawn under the status bar, so the caller * lays it out edge to edge and this does not pad for insets. @@ -24,19 +32,23 @@ internal val ProfileCoverHeight = 214.dp * rendition downloads; with no picture the cover is the plain surface colour. Loading is * [ProfileAvatar]'s — the same rendition, re-mint and cache path every avatar uses — and [access] * names the surface the picture is read from, which is what authorizes re-minting it. + * + * [height] is the profile's own by default. Pass [Dp.Unspecified] to size the cover from [modifier] + * instead, e.g. with `aspectRatio(ProfileCoverAspectRatio)`. */ @Composable fun ProfileCover( image: MediaItem?, access: BlobAccessContext, modifier: Modifier = Modifier, + height: Dp = ProfileCoverHeight, ) { ProfileAvatar( image = image, access = access, modifier = modifier .fillMaxWidth() - .height(ProfileCoverHeight), + .then(if (height.isSpecified) Modifier.height(height) else Modifier), background = SolidColor(CodeTheme.colors.surfaceVariant), fallback = { }, ) From 2da9efb3e551882a991777c2d7b9417e4240c694 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 16:34:07 -0400 Subject: [PATCH 3/9] fix(user-profile): focus editor fields after the push lands --- .../core/ui/transitions/FocusWhenSettled.kt | 38 +++++++++++++ .../userprofile/UserProfileSetupFlowScreen.kt | 57 +++++++++++-------- .../userprofile/internal/bio/EditBioScreen.kt | 5 +- .../internal/name/NameEntryScreen.kt | 5 +- .../internal/username/UsernameEntryScreen.kt | 5 +- 5 files changed, 77 insertions(+), 33 deletions(-) create mode 100644 apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/transitions/FocusWhenSettled.kt diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/transitions/FocusWhenSettled.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/transitions/FocusWhenSettled.kt new file mode 100644 index 0000000000..57ef09f045 --- /dev/null +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/transitions/FocusWhenSettled.kt @@ -0,0 +1,38 @@ +package com.flipcash.app.core.ui.transitions + +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.core.Transition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.focus.FocusRequester +import androidx.navigation3.ui.LocalNavAnimatedContentScope +import kotlinx.coroutines.flow.first + +/** + * The enter transition of the screen that hosts a nested flow. A flow's own steps animate inside + * their own nav display, so the slide that brought the whole flow in is not visible to them + * through [LocalNavAnimatedContentScope]; the host provides it here. + */ +val LocalHostEnterTransition: ProvidableCompositionLocal?> = + compositionLocalOf { null } + +private val Transition.isSettled: Boolean + get() = currentState == EnterExitState.Visible && targetState == EnterExitState.Visible + +/** + * Asks [focusRequester] for focus once the screen has finished arriving, rather than on first + * composition. Focusing during a push raises the keyboard mid-slide, which resizes the screen under + * the transition and makes the field jump. + */ +@Composable +fun RequestFocusWhenSettled(focusRequester: FocusRequester) { + val own = LocalNavAnimatedContentScope.current.transition + val host = LocalHostEnterTransition.current + LaunchedEffect(own, host) { + snapshotFlow { own.isSettled && (host?.isSettled ?: true) }.first { it } + focusRequester.requestFocus() + } +} diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt index 0aedc01b25..2b7e985fbd 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt @@ -1,10 +1,13 @@ package com.flipcash.app.userprofile import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.LocalNavAnimatedContentScope import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.ui.transitions.LocalHostEnterTransition import com.flipcash.app.core.userprofile.UpdateProfileResult import com.flipcash.app.core.userprofile.UpdateProfileStep import com.flipcash.app.userprofile.internal.bio.EditBioScreen @@ -32,30 +35,36 @@ fun UpdateUserProfileFlowScreen( val steps = route.rememberInitialStack() - FlowHost( - steps = steps, - resumeAt = 0, - resultStateRegistry = resultStateRegistry, - // Walking off the end of the step list (the last proceed()) completes the flow. - completedResult = UpdateProfileResult.Success, - onExit = { reason, _ -> - val result: UpdateProfileResult = when (reason) { - is FlowExitReason.Completed -> reason.result - FlowExitReason.Canceled, - FlowExitReason.BackedOutOfRoot -> UpdateProfileResult.Canceled - } - outerNavigator.deliverFlowResult( - route = route, - value = NavResultOrCanceled.ReturnValue(result), - ) - if (route.target != null && result is UpdateProfileResult.Success) { - outerNavigator.replaceAll(route.target!!) - } else { - outerNavigator.pop() - } - }, - entryProvider = profileUpdateProvider(route), - ) + // The steps animate inside the flow's own nav display, so they can't see the slide that pushed + // the whole flow. Hand them that transition so a field only takes focus once it has landed. + val hostEnterTransition = LocalNavAnimatedContentScope.current.transition + + CompositionLocalProvider(LocalHostEnterTransition provides hostEnterTransition) { + FlowHost( + steps = steps, + resumeAt = 0, + resultStateRegistry = resultStateRegistry, + // Walking off the end of the step list (the last proceed()) completes the flow. + completedResult = UpdateProfileResult.Success, + onExit = { reason, _ -> + val result: UpdateProfileResult = when (reason) { + is FlowExitReason.Completed -> reason.result + FlowExitReason.Canceled, + FlowExitReason.BackedOutOfRoot -> UpdateProfileResult.Canceled + } + outerNavigator.deliverFlowResult( + route = route, + value = NavResultOrCanceled.ReturnValue(result), + ) + if (route.target != null && result is UpdateProfileResult.Success) { + outerNavigator.replaceAll(route.target!!) + } else { + outerNavigator.pop() + } + }, + entryProvider = profileUpdateProvider(route), + ) + } } private fun profileUpdateProvider( diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioScreen.kt index a6e48dd7d0..acec612dbf 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioScreen.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioScreen.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.ui.DisplayTextInput import com.flipcash.app.core.userprofile.UpdateProfileResult import com.flipcash.app.core.userprofile.UpdateProfileStep +import com.flipcash.app.core.ui.transitions.RequestFocusWhenSettled import com.flipcash.core.R import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme @@ -133,8 +134,6 @@ private fun EditBioScreenContent( ) } - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } + RequestFocusWhenSettled(focusRequester) } } 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 d3ccdda192..f81dea4687 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 @@ -31,6 +31,7 @@ import com.flipcash.app.core.ui.transitions.SharedTransition import com.flipcash.app.core.ui.transitions.sharedElementTransition import com.flipcash.app.core.userprofile.UpdateProfileResult import com.flipcash.app.core.userprofile.UpdateProfileStep +import com.flipcash.app.core.ui.transitions.RequestFocusWhenSettled import com.flipcash.core.R import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme @@ -156,8 +157,6 @@ private fun NameEntryScreenContent( ) } - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } + RequestFocusWhenSettled(focusRequester) } } \ No newline at end of file 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 ae981da758..e784c0160f 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 @@ -24,6 +24,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.ui.DisplayTextInput import com.flipcash.app.core.userprofile.UpdateProfileResult import com.flipcash.app.core.userprofile.UpdateProfileStep +import com.flipcash.app.core.ui.transitions.RequestFocusWhenSettled import com.flipcash.core.R import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme @@ -147,9 +148,7 @@ private fun UsernameEntryScreenContent( ) } - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } + RequestFocusWhenSettled(focusRequester) } } From 02d63b05ede34258cf894d23cbff8a9c074438a7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 16:38:26 -0400 Subject: [PATCH 4/9] feat(myaccount): add Edit Profile Cover, photo, name, username, bio and minimum to chat on one screen, each opening its editor. The You tab's Edit Profile opens it, and Settings loses its temporary Profile section. The username balance gate moves from features/menu to shared/userflags so both screens use it. --- .../ui/navigation/AppScreenContent.kt | 2 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 4 + .../core/src/main/res/values/strings.xml | 6 +- .../app/menu/internal/MenuScreenContent.kt | 6 +- .../app/menu/internal/MenuScreenViewModel.kt | 2 + .../features/myaccount/build.gradle.kts | 4 + .../app/myaccount/EditProfileScreen.kt | 61 +++++ .../flipcash/app/myaccount/SettingsScreen.kt | 13 - .../editprofile/EditProfileScreenContent.kt | 238 ++++++++++++++++++ .../editprofile/EditProfileViewModel.kt | 194 ++++++++++++++ .../internal/settings/SettingsMenuItems.kt | 45 ---- .../internal/settings/SettingsViewModel.kt | 32 +-- .../internal/SettingsViewModelTest.kt | 25 -- .../editprofile/EditProfileViewModelTest.kt | 65 +++++ .../flipcash/app/userflags}/UsernameGate.kt | 6 +- .../app/userflags}/UsernameGateTest.kt | 2 +- 16 files changed, 582 insertions(+), 123 deletions(-) create mode 100644 apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/EditProfileScreen.kt create mode 100644 apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileScreenContent.kt create mode 100644 apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModel.kt create mode 100644 apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModelTest.kt rename apps/flipcash/{features/menu/src/main/kotlin/com/flipcash/app/menu/internal => shared/userflags/src/main/kotlin/com/flipcash/app/userflags}/UsernameGate.kt (95%) rename apps/flipcash/{features/menu/src/test/kotlin/com/flipcash/app/menu/internal => shared/userflags/src/test/kotlin/com/flipcash/app/userflags}/UsernameGateTest.kt (98%) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index cb78b1a95e..54426c2173 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -51,6 +51,7 @@ import com.flipcash.app.menu.ProfileCardScreen import com.flipcash.app.menu.ProfileShareScreen import com.flipcash.app.myaccount.BlocklistScreen import com.flipcash.app.myaccount.UserProfileScreen +import com.flipcash.app.myaccount.EditProfileScreen import com.flipcash.app.myaccount.SettingsScreen import com.flipcash.app.scanner.ScannerScreen import com.flipcash.app.shareapp.ShareAppScreen @@ -171,6 +172,7 @@ fun appEntryProvider( annotatedEntry { key -> LabsScreen(onboarding = key.onboarding) } annotatedEntry { UserProfileScreen() } annotatedEntry { SettingsScreen() } + annotatedEntry { EditProfileScreen() } annotatedEntry { ProfileShareScreen() } annotatedEntry { ProfileCardScreen() } annotatedEntry { BlocklistScreen() } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index d70d89c45b..8c292363b2 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -317,6 +317,10 @@ sealed interface AppRoute : NavKey, Parcelable { sealed interface Menu : AppRoute { @Serializable data object Settings : Menu + + /** Your cover, photo, name, handle, bio and minimum to chat, each opening its editor. */ + @Serializable + data object EditProfile : Menu @Serializable data object BackupKey : Menu diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 8afd45dd8f..16afe0c36f 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -666,7 +666,6 @@ Contracts User Flags Account - Profile Security Privacy User Flags @@ -1340,6 +1339,11 @@ Edit Profile Claim your username › + Change photo + Name + Add a name + Claim your username + Not set Share User Profile %1$s · @%2$s diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt index 2ad7c00fa0..7f2c265658 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt @@ -137,11 +137,7 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { item(key = "profile_header") { OwnProfileHeader( profile = profileState.profile, - onEdit = { - // Slice 4 gives this its own screen; Settings is where profile - // editing lives until then. - viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) - }, + onEdit = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.EditProfile)) }, onShare = { viewModel.dispatchEvent(Event.ShareProfile) }, onClaimUsername = state.usernameProgress?.let { { viewModel.dispatchEvent(Event.ClaimUsername) } diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index 7cbb367352..3bede68c70 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -13,6 +13,8 @@ import com.flipcash.app.menu.internal.components.UsernameProgress import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.core.TotalBalanceProvider import com.flipcash.app.userflags.UserFlagsCoordinator +import com.flipcash.app.userflags.UsernameGate +import com.flipcash.app.userflags.usernameGate import com.flipcash.features.menu.R import com.flipcash.services.user.AuthState import com.flipcash.services.models.UserProfile diff --git a/apps/flipcash/features/myaccount/build.gradle.kts b/apps/flipcash/features/myaccount/build.gradle.kts index fbced78a9a..e338e521d6 100644 --- a/apps/flipcash/features/myaccount/build.gradle.kts +++ b/apps/flipcash/features/myaccount/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { implementation(libs.compose.paging) + implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:appsettings")) implementation(project(":apps:flipcash:shared:appupdates")) implementation(project(":apps:flipcash:shared:authentication")) @@ -22,7 +23,10 @@ dependencies { implementation(project(":apps:flipcash:shared:common-ui")) implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:featureflags")) + implementation(project(":apps:flipcash:shared:funding")) implementation(project(":apps:flipcash:shared:menu")) + implementation(project(":apps:flipcash:shared:payments")) + implementation(project(":apps:flipcash:shared:tokens:core")) implementation(project(":apps:flipcash:shared:userflags")) implementation(project(":libs:datetime")) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/EditProfileScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/EditProfileScreen.kt new file mode 100644 index 0000000000..7ea2c45e72 --- /dev/null +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/EditProfileScreen.kt @@ -0,0 +1,61 @@ +package com.flipcash.app.myaccount + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.AppRoute +import com.flipcash.app.myaccount.internal.editprofile.EditProfileScreenContent +import com.flipcash.app.myaccount.internal.editprofile.EditProfileViewModel +import com.flipcash.core.R +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@Composable +fun EditProfileScreen() { + val navigator = LocalCodeNavigator.current + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = { AppBarDefaults.Title(text = stringResource(R.string.action_editProfile)) }, + titleAlignment = Alignment.CenterHorizontally, + leftIcon = { AppBarDefaults.UpNavigation { navigator.pop() } }, + ) + EditProfileScreenContent(state = state, dispatch = viewModel::dispatchEvent) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.push( + AppRoute.UpdateUserProfile( + origin = AppRoute.Menu.EditProfile, + steps = listOf(it.step), + ) + ) + }.launchIn(this) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { navigator.push(it.screen) } + .launchIn(this) + } +} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/SettingsScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/SettingsScreen.kt index 231780cc08..810bf13fae 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/SettingsScreen.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/SettingsScreen.kt @@ -49,19 +49,6 @@ fun SettingsScreen() { SettingsScreen(viewModel) } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.push( - AppRoute.UpdateUserProfile( - origin = AppRoute.Menu.Settings, - steps = listOf(it.step), - ) - ) - }.launchIn(this) - } - LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileScreenContent.kt new file mode 100644 index 0000000000..538bbf6651 --- /dev/null +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileScreenContent.kt @@ -0,0 +1,238 @@ +package com.flipcash.app.myaccount.internal.editprofile + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.userprofile.UpdateProfileStep +import com.flipcash.core.R +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.shared.common.ui.profile.ProfileCover +import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 +import com.getcode.ui.components.ListItemDefaults +import androidx.compose.ui.unit.Dp + +// Carried from the iOS screen. +private val CoverHeight = 126.dp +private val AvatarSize = 68.dp +private val BadgeSize = 28.dp +private val BadgeIconSize = 16.dp + +@Composable +internal fun EditProfileScreenContent( + state: EditProfileViewModel.State, + dispatch: (EditProfileViewModel.Event) -> Unit, +) { + val grid = CodeTheme.dimens.staticGrid + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(horizontal = CodeTheme.dimens.inset, vertical = grid.x5), + ) { + val openStep = { step: UpdateProfileStep -> dispatch(EditProfileViewModel.Event.OpenStep(step)) } + + CoverAndPhoto( + state = state, + onChangeCover = { openStep(UpdateProfileStep.Cover) }, + onChangePhoto = { openStep(UpdateProfileStep.Photo) }, + ) + + Column( + modifier = Modifier.padding(top = grid.x4), + verticalArrangement = Arrangement.spacedBy(grid.x2), + ) { + FieldCard( + title = stringResource(R.string.label_editProfileName), + value = state.displayName.ifEmpty { null }, + placeholder = stringResource(R.string.placeholder_editProfileName), + onClick = { openStep(EditProfileViewModel.nameStep) }, + ) + if (state.usernameNeedsClaim) { + FieldCard( + title = stringResource(R.string.title_username), + value = stringResource(R.string.action_claimUsername), + placeholder = "", + valueIsPrompt = true, + onClick = { dispatch(EditProfileViewModel.Event.UsernameClicked) }, + ) + } else { + FieldCard( + title = stringResource(R.string.title_username), + value = "@${state.username.orEmpty()}", + placeholder = "", + onClick = { openStep(UpdateProfileStep.Username) }, + ) + } + FieldCard( + title = stringResource(R.string.title_bio), + value = state.bio.ifEmpty { null }, + placeholder = stringResource(R.string.placeholder_bio), + maxLines = 3, + onClick = { openStep(UpdateProfileStep.Bio) }, + ) + FieldCard( + title = stringResource(R.string.title_minimumToChat), + value = state.minimumToChat, + placeholder = stringResource(R.string.placeholder_minimumToChat), + onClick = { openStep(UpdateProfileStep.MinimumTip) }, + ) + } + } +} + +/** The cover card with the avatar hanging off its bottom edge, half over it. */ +@Composable +private fun CoverAndPhoto( + state: EditProfileViewModel.State, + onChangeCover: () -> Unit, + onChangePhoto: () -> Unit, +) { + val grid = CodeTheme.dimens.staticGrid + Box(modifier = Modifier.fillMaxWidth().height(CoverHeight + AvatarSize / 2)) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(CoverHeight) + .clip(CodeTheme.shapes.medium) + .clickable(onClick = onChangeCover), + ) { + ProfileCover( + image = state.cover, + access = BlobAccessContext.Owned, + height = Dp.Unspecified, + modifier = Modifier.fillMaxSize(), + ) + Text( + text = stringResource(R.string.action_changeCover), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(grid.x2) + .clip(CircleShape) + .background(CodeTheme.colors.background) + .padding(horizontal = grid.x2, vertical = grid.x1), + ) + } + + Row( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = grid.x3) + .clickable(onClick = onChangePhoto), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(grid.x2), + ) { + Box { + ContactAvatar( + image = state.avatar, + displayName = state.displayName, + access = BlobAccessContext.Owned, + modifier = Modifier + .size(AvatarSize) + .clip(CircleShape) + .border(grid.x1, CodeTheme.colors.background, CircleShape), + ) + CameraBadge(modifier = Modifier.align(Alignment.BottomEnd)) + } + Text( + text = stringResource(R.string.action_changePhoto), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + modifier = Modifier.padding(bottom = grid.x1), + ) + } + } +} + +@Composable +private fun CameraBadge(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(BadgeSize) + .clip(CircleShape) + // Opaque, so the avatar doesn't show through the tinted fill. + .background(CodeTheme.colors.background) + .background(White05), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_camera), + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(BadgeIconSize), + ) + } +} + +/** A tappable card: the field's title over its current value, with the disclosure chevron. */ +@Composable +private fun FieldCard( + title: String, + value: String?, + placeholder: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + valueIsPrompt: Boolean = false, + maxLines: Int = 1, +) { + val grid = CodeTheme.dimens.staticGrid + Row( + modifier = modifier + .fillMaxWidth() + .clip(CodeTheme.shapes.medium) + .background(White05) + .clickable(onClick = onClick) + .padding(horizontal = grid.x3, vertical = grid.x2), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(grid.x2), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + Text( + text = value ?: placeholder, + style = CodeTheme.typography.textMedium, + color = if (value == null || valueIsPrompt) { + CodeTheme.colors.textSecondary + } else { + CodeTheme.colors.textMain + }, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + ) + } + ListItemDefaults.Chevron() + } +} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModel.kt new file mode 100644 index 0000000000..a52551426f --- /dev/null +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModel.kt @@ -0,0 +1,194 @@ +package com.flipcash.app.myaccount.internal.editprofile + +import androidx.lifecycle.viewModelScope +import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.events.AddMoneyEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.DisplayNameSource +import com.flipcash.app.core.userprofile.UpdateProfileStep +import com.flipcash.app.funding.PurchaseMethodController +import com.flipcash.app.tokens.core.TotalBalanceProvider +import com.flipcash.app.userflags.UserFlagsCoordinator +import com.flipcash.app.userflags.UsernameGate +import com.flipcash.app.userflags.usernameGate +import com.flipcash.core.R +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.user.AuthState +import com.flipcash.services.user.UserManager +import com.flipcash.shared.payments.TipPaymentDelegate +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Fiat +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +/** The gate as the copy quotes it, `$100 USD` rather than `$100.00`. */ +private fun Fiat.formattedGate(): String = + formatted(rule = Fiat.FormattingRule.Truncated, suffix = currencyCode.name) + +@HiltViewModel +internal class EditProfileViewModel @Inject constructor( + userManager: UserManager, + userFlags: UserFlagsCoordinator, + totalBalance: TotalBalanceProvider, + tipPayments: TipPaymentDelegate, + private val purchaseMethodController: PurchaseMethodController, + private val analytics: FlipcashAnalytics, + private val resources: ResourceHelper, + dispatchers: DispatcherProvider, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, +) { + internal data class State( + val userId: ID? = null, + val displayName: String = "", + val username: String? = null, + /** No handle yet, or one the server assigned: the row offers the claim instead of the handle. */ + val usernameNeedsClaim: Boolean = true, + val bio: String = "", + val avatar: MediaItem? = null, + val cover: MediaItem? = null, + /** What others pay to open a chat, formatted. Null until it resolves. */ + val minimumToChat: String? = null, + val usernameGate: UsernameGate = UsernameGate.Unlocked, + val usernameMinimumBalance: String = "", + ) + + internal sealed interface Event { + data class OnProfileChanged(val profile: UserProfile) : Event + data class OnMinimumToChatChanged(val minimumToChat: String?) : Event + data class OnUsernameGateChanged(val gate: UsernameGate, val minimumBalance: String) : Event + + /** A field card's tap; the screen opens the step in the profile editor. */ + data class OpenStep(val step: UpdateProfileStep) : Event + data class OpenScreen(val screen: AppRoute) : Event + data object UsernameClicked : Event + data class PresentDepositOptions(val source: AddMoneySource) : Event + } + + init { + val profile = userManager.state + .filter { it.authState is AuthState.Ready } + .map { it.userProfile } + .distinctUntilChanged() + + profile + .mapNotNull { it } + .onEach { dispatchEvent(Event.OnProfileChanged(it)) } + .launchIn(viewModelScope) + + profile + .flatMapLatest { tipPayments.startChattingFee(it) } + .map { it?.formatted() } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnMinimumToChatChanged(it)) } + .launchIn(viewModelScope) + + combine( + profile.map { it?.username to (it?.isUsernameAutoAssigned == true) }, + userFlags.resolvedFlags.map { it.usernameMinBalance.effectiveValue }, + totalBalance.observeTotalBalance(), + ) { (username, isAutoAssigned), minimum, balance -> + Event.OnUsernameGateChanged( + gate = usernameGate(username, isAutoAssigned, minimum, balance), + minimumBalance = minimum.formattedGate(), + ) + } + .distinctUntilChanged() + .onEach { dispatchEvent(it) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { event -> + analytics.track(AddMoneyEvents.opened(event.source)) + purchaseMethodController.presentDepositOptions(popToRoot = true) + }.onEach { route -> dispatchEvent(Event.OpenScreen(route)) } + .launchIn(viewModelScope) + + // A held handle goes straight to its editor. Claiming goes through the same balance gate + // the You tab's progress card uses: the claim step once the balance clears the minimum, the + // rule stated otherwise. + eventFlow + .filterIsInstance() + .onEach { + val state = stateFlow.value + val gate = state.usernameGate + if (!state.usernameNeedsClaim || gate !is UsernameGate.Locked) { + dispatchEvent(Event.OpenStep(UpdateProfileStep.Username)) + return@onEach + } + BottomBarManager.showInfo( + title = resources.getString( + R.string.error_title_usernameMinimumBalance, + state.usernameMinimumBalance, + ), + message = resources.getString( + R.string.error_description_usernameMinimumBalance, + state.usernameMinimumBalance, + ), + actions = listOf( + BottomBarAction( + text = resources.getString(R.string.action_addMoney), + onClick = { + dispatchEvent(Event.PresentDepositOptions(AddMoneySource.USERNAME_SHORTFALL)) + }, + ), + BottomBarAction( + text = resources.getString(R.string.action_dismiss), + style = BottomBarManager.BottomBarButtonStyle.Text, + ), + ), + ) + } + .launchIn(viewModelScope) + } + + internal companion object { + val nameStep = UpdateProfileStep.Name(DisplayNameSource.MyAccount) + + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + is Event.OnProfileChanged -> { state -> + val profile = event.profile + state.copy( + userId = profile.userId, + displayName = profile.displayName, + username = profile.username, + usernameNeedsClaim = profile.username.isNullOrBlank() || profile.isUsernameAutoAssigned, + bio = profile.bio, + avatar = profile.profilePicture, + cover = profile.coverPicture, + ) + } + + is Event.OnMinimumToChatChanged -> { state -> state.copy(minimumToChat = event.minimumToChat) } + is Event.OnUsernameGateChanged -> { state -> + state.copy(usernameGate = event.gate, usernameMinimumBalance = event.minimumBalance) + } + + is Event.OpenStep, + is Event.OpenScreen, + Event.UsernameClicked, + is Event.PresentDepositOptions -> { state -> state } + } + } + } +} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsMenuItems.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsMenuItems.kt index 3c3d6abee5..957adf3271 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsMenuItems.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsMenuItems.kt @@ -3,8 +3,6 @@ package com.flipcash.app.myaccount.internal.settings import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContactMail import androidx.compose.material.icons.filled.Science -import androidx.compose.material.icons.outlined.AlternateEmail -import androidx.compose.material.icons.outlined.Badge import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Palette @@ -15,55 +13,12 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.DisplayNameSource -import com.flipcash.app.core.userprofile.UpdateProfileStep import com.flipcash.app.menu.FullMenuItem import com.flipcash.app.menu.StaffMenuItem import com.flipcash.core.R as CoreR import com.flipcash.features.myaccount.R import com.getcode.util.resources.icons.Delete -/** - * Each profile row lands straight on the one step it is about: [AppRoute.UpdateUserProfile] walks - * name, username, then photo, and these are single-step edits. - */ -internal data object ChangeDisplayName : FullMenuItem() { - override val icon: Painter - @Composable get() = rememberVectorPainter(Icons.Outlined.Badge) - override val name: String - @Composable get() = stringResource(CoreR.string.title_displayName) - override val action: SettingsViewModel.Event = - SettingsViewModel.Event.OnEditProfile(UpdateProfileStep.Name(DisplayNameSource.MyAccount)) -} - -/** - * Shown only once a handle is claimed. Claiming the first one belongs to the You tab's card, which - * carries the minimum-balance gate; an account holding a handle has already cleared it. - */ -internal data object ChangeUsername : FullMenuItem() { - override val icon: Painter - @Composable get() = rememberVectorPainter(Icons.Outlined.AlternateEmail) - override val name: String - @Composable get() = stringResource(CoreR.string.title_username) - override val action: SettingsViewModel.Event = SettingsViewModel.Event.OnEditProfile(UpdateProfileStep.Username) -} - -internal data object ProfilePicture : FullMenuItem() { - override val icon: Painter - @Composable get() = painterResource(CoreR.drawable.ic_profile_picture) - override val name: String - @Composable get() = stringResource(CoreR.string.title_profilePicture) - override val action: SettingsViewModel.Event = SettingsViewModel.Event.OnEditProfile(UpdateProfileStep.Photo) -} - -internal data object MinimumTip : FullMenuItem() { - override val icon: Painter - @Composable get() = painterResource(CoreR.drawable.ic_coins) - override val name: String - @Composable get() = stringResource(CoreR.string.title_minimumToChat) - override val action: SettingsViewModel.Event = SettingsViewModel.Event.OnEditProfile(UpdateProfileStep.MinimumTip) -} - internal data object AccessKey : FullMenuItem() { override val icon: Painter @Composable get() = painterResource(CoreR.drawable.ic_hardware_security_key) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt index e5b942daf2..b375d011a0 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt @@ -7,7 +7,6 @@ import com.flipcash.app.appsettings.AppSettingsCoordinator import com.flipcash.app.auth.AuthManager import com.flipcash.app.core.AppRoute import com.flipcash.app.core.android.VersionInfo -import com.flipcash.app.core.userprofile.UpdateProfileStep import com.flipcash.app.featureflags.BetaFeature import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.menu.MenuItem @@ -18,7 +17,6 @@ import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.core.R import com.flipcash.features.myaccount.BuildConfig import com.flipcash.libs.coroutines.DispatcherProvider -import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper @@ -47,7 +45,6 @@ internal class SettingsViewModel @Inject constructor( private val appSettings: AppSettingsCoordinator, private val featureFlags: FeatureFlagController, userFlags: UserFlagsCoordinator, - userManager: UserManager, versionInfo: VersionInfo, releaseStageProvider: ReleaseStageProvider, private val resources: ResourceHelper, @@ -71,8 +68,6 @@ internal class SettingsViewModel @Inject constructor( // Whether the version-footer override specifically is on; staff don't count toward the // tap-to-unlock easter egg. val unlockedBetaFeaturesManually: Boolean = false, - // Whether the account holds a handle. Changing one presupposes having one. - val usernameClaimed: Boolean = false, val flags: List = emptyList(), val versionTapCount: Int = 0, val appVersionInfo: VersionInfo = VersionInfo(), @@ -82,7 +77,6 @@ internal class SettingsViewModel @Inject constructor( val sections: List = buildSections( biometricsSupported = true, betaUnlocked = false, - usernameClaimed = false, flags = emptyList(), ), ) @@ -101,11 +95,9 @@ internal class SettingsViewModel @Inject constructor( ) : Event /** Dispatched only after the screen's biometric prompt succeeds. */ data object OnBiometricsToggled : Event - data class OnUsernameClaimChanged(val claimed: Boolean) : Event data class OnAppVersionUpdated(val versionInfo: VersionInfo) : Event data class OnReleaseTrackDetermined(val track: String) : Event - data class OnEditProfile(val step: UpdateProfileStep) : Event data class OpenScreen(val screen: AppRoute) : Event data object OpenBillPlayground : Event data object OnAccessKeyClicked : Event @@ -140,13 +132,6 @@ internal class SettingsViewModel @Inject constructor( .onEach { dispatchEvent(Event.OnFeatureFlagsUpdated(it)) } .launchIn(viewModelScope) - userManager.state - .map { it.userProfile?.username } - .map { username -> !username.isNullOrBlank() } - .distinctUntilChanged() - .onEach { dispatchEvent(Event.OnUsernameClaimChanged(it)) } - .launchIn(viewModelScope) - appSettings.settings() .map { items -> items.find { it.setting.type == AppSettingValue.BiometricsRequired } } .onEach { item -> @@ -287,21 +272,16 @@ internal class SettingsViewModel @Inject constructor( /** * Biometrics drops out on hardware that can't offer it; Beta-badged rows need the beta - * unlock; changing a handle needs one to already be claimed; flag-gated rows additionally + * unlock; flag-gated rows additionally * need their flag switched on server-side. Bill Customizer stays out, as it did on the * Advanced screen. */ internal fun buildSections( biometricsSupported: Boolean, betaUnlocked: Boolean, - usernameClaimed: Boolean, flags: List, ): List { val all = listOf( - SettingsSection( - R.string.title_settingsSectionProfile, - listOf(ChangeDisplayName, ChangeUsername, ProfilePicture, MinimumTip), - ), SettingsSection( R.string.title_settingsSectionSecurity, listOf(AccessKey, RequireBiometrics), @@ -321,7 +301,6 @@ internal class SettingsViewModel @Inject constructor( section.copy( items = section.items .filterNot { it == RequireBiometrics && !biometricsSupported } - .filterNot { it == ChangeUsername && !usernameClaimed } .filter { it !is StaffMenuItem<*> || betaUnlocked } .filter { item -> val flag = item.featureFlag ?: return@filter true @@ -335,14 +314,12 @@ internal class SettingsViewModel @Inject constructor( private fun State.rebuilt( biometricsSupported: Boolean = this.biometricsSupported, betaUnlocked: Boolean = this.betaUnlocked, - usernameClaimed: Boolean = this.usernameClaimed, flags: List = this.flags, ) = copy( biometricsSupported = biometricsSupported, betaUnlocked = betaUnlocked, - usernameClaimed = usernameClaimed, flags = flags, - sections = buildSections(biometricsSupported, betaUnlocked, usernameClaimed, flags), + sections = buildSections(biometricsSupported, betaUnlocked, flags), ) val updateStateForEvent: (Event) -> ((State) -> State) = { event -> @@ -354,10 +331,6 @@ internal class SettingsViewModel @Inject constructor( is Event.OnFeatureFlagsUpdated -> { state -> state.rebuilt(flags = event.flags) } - is Event.OnUsernameClaimChanged -> { state -> - state.rebuilt(usernameClaimed = event.claimed) - } - is Event.OnBiometricsSettingChanged -> { state -> state.rebuilt(biometricsSupported = event.supported).copy( biometricsRequired = event.required, @@ -374,7 +347,6 @@ internal class SettingsViewModel @Inject constructor( is Event.OnReleaseTrackDetermined -> { state -> state.copy(releaseTrack = event.track) } Event.OnBiometricsToggled, - is Event.OnEditProfile, is Event.OpenScreen, Event.OpenBillPlayground, Event.OnAccessKeyClicked, diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt index da00ef73dd..4fa5c7829d 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt @@ -10,13 +10,9 @@ import com.flipcash.app.myaccount.internal.settings.AccessKey import com.flipcash.app.myaccount.internal.settings.AccountInfo import com.flipcash.app.myaccount.internal.settings.BetaFlags import com.flipcash.app.myaccount.internal.settings.Blocklist -import com.flipcash.app.myaccount.internal.settings.ChangeDisplayName -import com.flipcash.app.myaccount.internal.settings.ChangeUsername import com.flipcash.app.myaccount.internal.settings.DeleteAccount import com.flipcash.app.myaccount.internal.settings.DeviceLogs import com.flipcash.app.myaccount.internal.settings.LogOut -import com.flipcash.app.myaccount.internal.settings.MinimumTip -import com.flipcash.app.myaccount.internal.settings.ProfilePicture import com.flipcash.app.myaccount.internal.settings.RequireBiometrics import com.flipcash.app.myaccount.internal.settings.SettingsViewModel import com.flipcash.app.myaccount.internal.settings.SwitchAccount @@ -26,7 +22,6 @@ import com.flipcash.app.userflags.ResolvedFlag import com.flipcash.app.userflags.ResolvedUserFlags import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.core.R -import com.flipcash.services.user.UserManager import com.getcode.util.resources.FakeResourceHelper import io.mockk.every import io.mockk.mockk @@ -69,7 +64,6 @@ class SettingsViewModelTest { val state = SettingsViewModel.State() assertEquals( listOf( - R.string.title_settingsSectionProfile, R.string.title_settingsSectionSecurity, R.string.title_settingsSectionPrivacy, R.string.title_advancedFeatures, @@ -79,7 +73,6 @@ class SettingsViewModelTest { ) assertEquals( listOf( - ChangeDisplayName, ProfilePicture, MinimumTip, AccessKey, RequireBiometrics, Blocklist, DeviceLogs, BetaFlags, @@ -114,19 +107,6 @@ class SettingsViewModelTest { assertEquals(listOf(AccountInfo, LogOut, DeleteAccount), account.items) } - @Test - fun `username row is absent without a claimed handle`() { - assertFalse(ChangeUsername in SettingsViewModel.State().items()) - } - - @Test - fun `username row follows the claimed handle and sits after the display name`() { - val state = SettingsViewModel.State().after(SettingsViewModel.Event.OnUsernameClaimChanged(true)) - val profile = state.sections.first().items - assertEquals(listOf(ChangeDisplayName, ChangeUsername, ProfilePicture, MinimumTip), profile) - assertFalse(ChangeUsername in state.after(SettingsViewModel.Event.OnUsernameClaimChanged(false)).items()) - } - @Test fun `unsupported biometrics hides the row`() { val state = SettingsViewModel.State().after( @@ -140,11 +120,9 @@ class SettingsViewModelTest { fun `later events keep earlier conditions`() { val state = SettingsViewModel.State().after( SettingsViewModel.Event.OnBiometricsSettingChanged(required = true, supported = false, available = false), - SettingsViewModel.Event.OnUsernameClaimChanged(true), SettingsViewModel.Event.OnBetaFeaturesUnlocked(true, manual = true), ) assertFalse(RequireBiometrics in state.items()) - assertTrue(ChangeUsername in state.items()) assertTrue(SwitchAccount in state.items()) } @@ -194,14 +172,11 @@ class SettingsViewModelTest { val appSettings = mockk(relaxed = true) every { appSettings.settings() } returns emptyFlow() - val userManager = mockk(relaxed = true) - every { userManager.state } returns MutableStateFlow(UserManager.State()) return SettingsViewModel( appSettings = appSettings, featureFlags = featureFlags, userFlags = userFlags, - userManager = userManager, versionInfo = VersionInfo("1.0", 1), releaseStageProvider = mockk(relaxed = true), resources = FakeResourceHelper(), diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModelTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModelTest.kt new file mode 100644 index 0000000000..fe263d1c4b --- /dev/null +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModelTest.kt @@ -0,0 +1,65 @@ +package com.flipcash.app.myaccount.internal.editprofile + +import com.flipcash.services.models.UserProfile +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class EditProfileViewModelTest { + + private val reduce = EditProfileViewModel.updateStateForEvent + + private fun profile( + name: String = "Brandon", + username: String? = null, + autoAssigned: Boolean = false, + bio: String = "", + ) = UserProfile.Empty.copy( + displayName = name, + username = username, + isUsernameAutoAssigned = autoAssigned, + bio = bio, + ) + + private fun EditProfileViewModel.State.after(profile: UserProfile) = + reduce(EditProfileViewModel.Event.OnProfileChanged(profile))(this) + + @Test + fun `a profile with no username still needs the claim`() { + assertTrue(EditProfileViewModel.State().after(profile(username = null)).usernameNeedsClaim) + assertTrue(EditProfileViewModel.State().after(profile(username = "")).usernameNeedsClaim) + } + + @Test + fun `a server-assigned username still needs the claim`() { + val state = EditProfileViewModel.State().after(profile(username = "brandon123", autoAssigned = true)) + assertTrue(state.usernameNeedsClaim) + } + + @Test + fun `a chosen username does not need the claim and is carried for display`() { + val state = EditProfileViewModel.State().after(profile(username = "mcansh")) + assertFalse(state.usernameNeedsClaim) + assertEquals("mcansh", state.username) + } + + @Test + fun `name and bio follow the profile, including being cleared`() { + val filled = EditProfileViewModel.State().after(profile(name = "Brandon", bio = "hello")) + assertEquals("Brandon", filled.displayName) + assertEquals("hello", filled.bio) + + val cleared = filled.after(profile(name = "", bio = "")) + assertEquals("", cleared.displayName) + assertEquals("", cleared.bio) + } + + @Test + fun `minimum to chat is unknown until it resolves`() { + assertNull(EditProfileViewModel.State().minimumToChat) + val state = reduce(EditProfileViewModel.Event.OnMinimumToChatChanged("$1.00"))(EditProfileViewModel.State()) + assertEquals("$1.00", state.minimumToChat) + } +} diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/UsernameGate.kt b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UsernameGate.kt similarity index 95% rename from apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/UsernameGate.kt rename to apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UsernameGate.kt index f4d1e0f65b..f7669e1cd1 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/UsernameGate.kt +++ b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UsernameGate.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.menu.internal +package com.flipcash.app.userflags import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.minus @@ -15,7 +15,7 @@ import com.getcode.opencode.model.financial.minus * * Mirrors iOS `usernameGate(session:minimum:)` in `UsernameGate.swift`. */ -internal sealed interface UsernameGate { +sealed interface UsernameGate { /** * A handle the user chose is already claimed — the nudge is spent, and changing it lives in * My Account. A handle the server assigned does not count: the user hasn't picked it. @@ -44,7 +44,7 @@ internal sealed interface UsernameGate { * @param minimum the balance the account must hold to claim, from the `usernameMinBalance` flag. * @param balance the account's total balance, in the same currency as [minimum]. */ -internal fun usernameGate( +fun usernameGate( username: String?, isAutoAssigned: Boolean, minimum: Fiat, diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/UsernameGateTest.kt b/apps/flipcash/shared/userflags/src/test/kotlin/com/flipcash/app/userflags/UsernameGateTest.kt similarity index 98% rename from apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/UsernameGateTest.kt rename to apps/flipcash/shared/userflags/src/test/kotlin/com/flipcash/app/userflags/UsernameGateTest.kt index bd14478968..d43994c9c6 100644 --- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/UsernameGateTest.kt +++ b/apps/flipcash/shared/userflags/src/test/kotlin/com/flipcash/app/userflags/UsernameGateTest.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.menu.internal +package com.flipcash.app.userflags import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat From 5c09393c549ae5ffba24a7a62f93e92279689174 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 16:39:55 -0400 Subject: [PATCH 5/9] feat(menu): name the profile card export after the chat The exported file is now "Chat with ", or "Profile Card" when the profile has no name, instead of one derived from the card signature. --- .../flipcash/app/core/share/TipCodeExport.kt | 24 ++++++++++- .../app/core/share/TipCodeExporter.kt | 5 +++ .../core/src/main/res/values/strings.xml | 2 + .../app/core/share/TipCodeExportNameTest.kt | 40 +++++++++++++++++++ .../app/menu/internal/ProfileCardViewModel.kt | 8 +++- 5 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/share/TipCodeExportNameTest.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodeExport.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodeExport.kt index a77fbd1e79..3acf47ed3d 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodeExport.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodeExport.kt @@ -50,8 +50,12 @@ object TipCodeExportStorage { * Name is user-visible in some share targets ("save to Files"), hence the readable prefix * rather than a bare hash. */ - fun file(context: Context, signature: String, format: TipCodeExportFormat): File = - File(dir(context), "flipcash-code-$signature.${format.extension}") + fun file( + context: Context, + signature: String, + format: TipCodeExportFormat, + baseName: String? = null, + ): File = File(dir(context), "${exportFileBaseName(baseName) ?: "flipcash-code-$signature"}.${format.extension}") fun uriFor(context: Context, file: File): Uri = FileProvider.getUriForFile(context, TipCodePreviewStorage.authority(context), file) @@ -67,3 +71,19 @@ object TipCodeExportStorage { private const val DEFAULT_MAX_TOTAL_BYTES = 8L * 1024 * 1024 // 8 MiB private const val DEFAULT_MAX_AGE_MILLIS = 24L * 60 * 60 * 1000 // 1 day } + +private const val MaxBaseNameLength = 80 +private val HostileFileNameChars = Regex("[\\\\/:*?\"<>|\\p{Cntrl}]") + +/** + * [raw] made safe to use as a file name, or null when nothing usable is left. Drops path separators + * and the characters common file systems reject, trims, and caps the length without splitting a + * surrogate pair. + */ +fun exportFileBaseName(raw: String?): String? { + val cleaned = raw.orEmpty().replace(HostileFileNameChars, "").trim() + if (cleaned.isEmpty()) return null + var end = minOf(cleaned.length, MaxBaseNameLength) + if (end < cleaned.length && Character.isHighSurrogate(cleaned[end - 1])) end-- + return cleaned.substring(0, end).trim().ifEmpty { null } +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodeExporter.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodeExporter.kt index 25782ebd6a..7087df88c7 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodeExporter.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodeExporter.kt @@ -37,11 +37,15 @@ class TipCodeExporter @Inject constructor( * Writes [card]'s code in [format] and returns a `content://` handle to it, or `null` if the * write failed — callers should degrade (share the URL alone) rather than surface an error. * + * [baseName] names the file (the extension is added) where the share target shows it; without one + * the file is named from the card's signature. + * * [sizePx] applies to [TipCodeExportFormat.Png] only; the SVG carries a `viewBox` and scales. */ suspend fun export( card: Scannable.TipCard, format: TipCodeExportFormat, + baseName: String? = null, sizePx: Int = DEFAULT_PNG_SIZE_PX, ): TipCodeExport? = withContext(dispatchers.IO) { val payload = card.data.toByteArray() @@ -52,6 +56,7 @@ class TipCodeExporter @Inject constructor( context = context, signature = tipCodePreviewSignature(card), format = format, + baseName = baseName, ) when (format) { TipCodeExportFormat.Png -> writePng(payload, sizePx, file) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 16afe0c36f..84119fecc3 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1359,6 +1359,8 @@ SVG Stays sharp at any size My Flipcash Code + Chat with %1$s + Profile Card Couldn\'t Export We were unable to export your tip code. Please try again - - - diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 84119fecc3..3d65ff47a3 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1363,10 +1363,6 @@ Profile Card Couldn\'t Export We were unable to export your tip code. Please try again - - Display Name - Profile Picture Bio diff --git a/apps/flipcash/features/menu/build.gradle.kts b/apps/flipcash/features/menu/build.gradle.kts index d3f8e3c8a6..d16b3fbd7d 100644 --- a/apps/flipcash/features/menu/build.gradle.kts +++ b/apps/flipcash/features/menu/build.gradle.kts @@ -10,6 +10,8 @@ dependencies { testImplementation(kotlin("test")) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.robolectric) + testImplementation(libs.bundles.unit.testing) + testImplementation(testFixtures(project(":ui:resources"))) implementation(libs.bundles.haze) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModel.kt index 1037f842ad..b74228a77f 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.share.TipCodeExportFormat import com.flipcash.app.core.share.TipCodeExporter +import com.flipcash.app.core.share.exportFileBaseName import com.flipcash.app.shareable.ShareSheetController import com.flipcash.app.shareable.Shareable import com.flipcash.features.menu.R @@ -52,11 +53,14 @@ internal class ProfileCardViewModel @Inject constructor( private fun export(format: TipCodeExportFormat) { val card = card ?: return viewModelScope.launch { - val name = card.user.displayName.trim() - val baseName = if (name.isEmpty()) { + // A name of nothing but characters a file system rejects sanitises to null, which must + // land on the fallback rather than "Chat with ". The exporter sanitises and caps the + // assembled name again. + val safeName = exportFileBaseName(card.user.displayName) + val baseName = if (safeName == null) { resources.getString(R.string.label_profileCardFileNameFallback) } else { - resources.getString(R.string.label_profileCardFileName, name) + resources.getString(R.string.label_profileCardFileName, safeName) } val export = tipCodeExporter.export(card, format, baseName = baseName) if (export == null) { diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModelTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModelTest.kt new file mode 100644 index 0000000000..177e991720 --- /dev/null +++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModelTest.kt @@ -0,0 +1,85 @@ +package com.flipcash.app.menu.internal + +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.bill.Scannable +import com.flipcash.app.core.share.TipCodeExporter +import com.flipcash.app.shareable.ShareSheetController +import com.flipcash.features.menu.R +import com.flipcash.services.models.UserProfile +import com.flipcash.shared.tipping.TippingCoordinator +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.FakeResourceHelper +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class ProfileCardViewModelTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val exporter = mockk() + private val resources = FakeResourceHelper() + .stub(R.string.label_profileCardFileName, "Chat with %1\$s") + .stub(R.string.label_profileCardFileNameFallback, "Profile Card") + + @Before + @After + fun clearAlerts() { + BottomBarManager.clear() + } + + /** Exports with [displayName] and returns the base name the exporter was asked to use. */ + private fun exportedBaseName(displayName: String): String? { + val card = Scannable.TipCard( + data = listOf(1), + user = UserProfile.Empty.copy(displayName = displayName), + ) + val tipping = mockk() + coEvery { tipping.resolveTipCard() } returns Result.success(card) + val baseName = slot() + coEvery { exporter.export(any(), any(), capture(baseName), any()) } returns null + + val vm = ProfileCardViewModel( + tippingCoordinator = tipping, + tipCodeExporter = exporter, + shareable = mockk(relaxed = true), + resources = resources, + ) + vm.download() + BottomBarManager.messages.value.first().actions.first().onClick() + + return if (baseName.isCaptured) baseName.captured else null + } + + @Test + fun `a plain name is used in the file name`() = runTest(mainCoroutineRule.dispatcher) { + assertEquals("Chat with Ada", exportedBaseName("Ada")) + } + + @Test + fun `hostile characters are dropped from the name`() = runTest(mainCoroutineRule.dispatcher) { + assertEquals("Chat with ab", exportedBaseName("a/b")) + } + + @Test + fun `a name of only hostile characters falls back to the generic name`() = runTest(mainCoroutineRule.dispatcher) { + assertEquals("Profile Card", exportedBaseName("///")) + BottomBarManager.clear() + assertEquals("Profile Card", exportedBaseName("<>")) + } + + @Test + fun `a blank name falls back to the generic name`() = runTest(mainCoroutineRule.dispatcher) { + assertEquals("Profile Card", exportedBaseName(" ")) + } +} diff --git a/apps/flipcash/features/messenger/src/main/res/drawable/ic_chat_bubble.xml b/apps/flipcash/features/messenger/src/main/res/drawable/ic_chat_bubble.xml index e44defc076..2440419eb7 100644 --- a/apps/flipcash/features/messenger/src/main/res/drawable/ic_chat_bubble.xml +++ b/apps/flipcash/features/messenger/src/main/res/drawable/ic_chat_bubble.xml @@ -1,5 +1,5 @@ + ships is translated into a 0..960 one, matching the other icons here. --> (relaxed = true) + + @Before + @After + fun clearAlerts() { + BottomBarManager.clear() + } + + private fun TestScope.viewModel(): EditProfileViewModel { + val userManager = mockk(relaxed = true) + every { userManager.state } returns MutableStateFlow(UserManager.State()) + val userFlags = mockk(relaxed = true) + every { userFlags.resolvedFlags } returns MutableStateFlow(mockk(relaxed = true)) + val totalBalance = mockk() + every { totalBalance.observeTotalBalance() } returns emptyFlow() + return EditProfileViewModel( + userManager = userManager, + userFlags = userFlags, + totalBalance = totalBalance, + tipPayments = mockk(relaxed = true), + purchaseMethodController = purchaseMethodController, + analytics = mockk(relaxed = true), + resources = FakeResourceHelper(), + dispatchers = TestDispatchers(testScheduler), + ) + } + + private fun locked() = UsernameGate.Locked( + minimum = mockk(relaxed = true), + shortfall = mockk(relaxed = true), + fraction = 0.5f, + ) + + /** Seeds the claim and gate state, clicks the username row, and returns every event it produced. */ + private fun TestScope.clickUsername( + vm: EditProfileViewModel, + username: String?, + gate: UsernameGate, + ): MutableList { + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { vm.eventFlow.collect { events += it } } + vm.dispatchEvent(EditProfileViewModel.Event.OnProfileChanged(profile(username = username))) + vm.dispatchEvent(EditProfileViewModel.Event.OnUsernameGateChanged(gate, "$100 USD")) + advanceUntilIdle() + events.clear() + vm.dispatchEvent(EditProfileViewModel.Event.UsernameClicked) + advanceUntilIdle() + return events + } + + private val openedUsernameStep = EditProfileViewModel.Event.OpenStep(UpdateProfileStep.Username) + + @Test + fun `claiming while locked shows the balance minimum and does not open the step`() = + runTest(mainCoroutineRule.dispatcher) { + val events = clickUsername(viewModel(), username = null, gate = locked()) + + assertTrue(events.none { it is EditProfileViewModel.Event.OpenStep }) + assertEquals( + "error_title_usernameMinimumBalance", + BottomBarManager.messages.value.firstOrNull()?.title, + ) + } + + @Test + fun `claiming while unlocked opens the username step`() = runTest(mainCoroutineRule.dispatcher) { + val events = clickUsername(viewModel(), username = null, gate = UsernameGate.Unlocked) + + assertTrue(openedUsernameStep in events) + assertTrue(BottomBarManager.messages.value.isEmpty()) + } + + @Test + fun `a held username opens the step even while locked`() = runTest(mainCoroutineRule.dispatcher) { + val events = clickUsername(viewModel(), username = "mcansh", gate = locked()) + + assertTrue(openedUsernameStep in events) + assertTrue(BottomBarManager.messages.value.isEmpty()) + } + + @Test + fun `the Add Money action presents deposit options`() = runTest(mainCoroutineRule.dispatcher) { + val route = mockk() + coEvery { purchaseMethodController.presentDepositOptions(popToRoot = true) } returns route + val vm = viewModel() + val events = clickUsername(vm, username = null, gate = locked()) + assertTrue(events.none { it is EditProfileViewModel.Event.OpenStep }) + + events.clear() + val addMoney = BottomBarManager.messages.value.first().actions + .first { it.text.text == "action_addMoney" } + addMoney.onClick() + advanceUntilIdle() + + assertTrue(EditProfileViewModel.Event.PresentDepositOptions(AddMoneySource.USERNAME_SHORTFALL) in events) + coVerify { purchaseMethodController.presentDepositOptions(popToRoot = true) } + assertTrue(EditProfileViewModel.Event.OpenScreen(route) in events) + } + private val reduce = EditProfileViewModel.updateStateForEvent private fun profile( diff --git a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioViewModelTest.kt b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioViewModelTest.kt index 3fb463d9b5..4381586288 100644 --- a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioViewModelTest.kt +++ b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioViewModelTest.kt @@ -147,4 +147,18 @@ class EditBioViewModelTest { verify(profileController).setBio("New bio") assertEquals(true, saved.any { it is EditBioViewModel.Event.OnBioSaved }) } + + @Test + fun `clearing a non-blank bio sends an empty bio`() = runTest(mainCoroutineRule.dispatcher) { + whenever(profileController.setBio(any())).thenReturn(Result.success(Unit)) + val vm = viewModel("Hello") + advanceUntilIdle() + vm.type { replace(0, length, "") } + advanceUntilIdle() + + vm.dispatchEvent(EditBioViewModel.Event.Save) + advanceUntilIdle() + + verify(profileController).setBio("") + } } diff --git a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModelTest.kt b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModelTest.kt index d60c408395..48884642d4 100644 --- a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModelTest.kt +++ b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModelTest.kt @@ -6,6 +6,12 @@ import com.flipcash.app.blob.ImageUploadPreparer import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.services.controllers.ModerationController +import com.flipcash.services.models.BlobRejectedException +import com.flipcash.services.models.SetCoverPictureError +import com.flipcash.services.models.chat.BlobRejection +import com.flipcash.services.models.chat.RejectionReason +import com.flipcash.services.models.ModerationResult +import com.getcode.manager.BottomBarManager import com.flipcash.services.controllers.ProfileController import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.BlobId @@ -72,8 +78,14 @@ class PhotoSelectionViewModelTest { every { MimeTypeMap.getSingleton() } returns map } + @Before + fun clearAlerts() { + BottomBarManager.clear() + } + @After fun unstubMimeTypeMap() { + BottomBarManager.clear() unmockkStatic(MimeTypeMap::class) } @@ -149,4 +161,77 @@ class PhotoSelectionViewModelTest { verify(profileController).setProfilePicture(blobId) verify(profileController, never()).setCoverPicture(any()) } + + private fun rejection(reason: RejectionReason, category: ModerationResult.FlaggedCategory = ModerationResult.FlaggedCategory.NONE) = + BlobRejectedException(BlobRejection(reason, category)) + + /** Picks an image, fails the upload with [cause], and returns the title of the alert shown. */ + private suspend fun TestScope.failUploadWith(slot: PhotoSelectionViewModel.Slot, cause: Throwable): String? { + whenever(blobStorage.upload(any(), any(), anyOrNull())).thenReturn(Result.failure(cause)) + val vm = viewModel(slot) + advanceUntilIdle() + vm.dispatchEvent(PhotoSelectionViewModel.Event.OnImageCached(pick, "image/jpeg")) + vm.dispatchEvent(PhotoSelectionViewModel.Event.CheckImage) + advanceUntilIdle() + return BottomBarManager.messages.value.firstOrNull()?.title + } + + @Test + fun `a cover blob rejected by the profile call shows the cover alert`() = runTest(mainCoroutineRule.dispatcher) { + stubUpload() + whenever(profileController.setCoverPicture(any())).thenReturn(Result.failure(SetCoverPictureError.BlobRejected())) + val vm = viewModel(PhotoSelectionViewModel.Slot.Cover) + advanceUntilIdle() + vm.dispatchEvent(PhotoSelectionViewModel.Event.OnImageCached(pick, "image/jpeg")) + vm.dispatchEvent(PhotoSelectionViewModel.Event.CheckImage) + advanceUntilIdle() + + assertEquals("error_title_coverNotAllowed", BottomBarManager.messages.value.firstOrNull()?.title) + } + + @Test + fun `a moderation rejection on a cover shows the cover alert`() = runTest(mainCoroutineRule.dispatcher) { + val title = failUploadWith( + PhotoSelectionViewModel.Slot.Cover, + rejection(RejectionReason.MODERATION, ModerationResult.FlaggedCategory.NSFW), + ) + assertEquals("error_title_coverNotAllowed", title) + } + + @Test + fun `an unclassified rejection on a cover shows the cover alert`() = runTest(mainCoroutineRule.dispatcher) { + assertEquals( + "error_title_coverNotAllowed", + failUploadWith(PhotoSelectionViewModel.Slot.Cover, rejection(RejectionReason.UNKNOWN)), + ) + } + + @Test + fun `a non-moderation rejection on a cover gets the generic wording`() = runTest(mainCoroutineRule.dispatcher) { + assertEquals( + "error_title_moderationFailed", + failUploadWith(PhotoSelectionViewModel.Slot.Cover, rejection(RejectionReason.TOO_LARGE)), + ) + } + + @Test + fun `a moderation rejection on an avatar keeps its per-category message`() = runTest(mainCoroutineRule.dispatcher) { + val vm = failUploadWith( + PhotoSelectionViewModel.Slot.Avatar, + rejection(RejectionReason.MODERATION, ModerationResult.FlaggedCategory.NSFW), + ) + assertEquals("error_title_profilePhotoNotAllowed", vm) + assertEquals( + "error_description_profilePhotoNotAllowedFlaggedNsfw", + BottomBarManager.messages.value.firstOrNull()?.subtitle, + ) + } + + @Test + fun `an unclassified rejection on an avatar keeps the image-not-allowed message`() = runTest(mainCoroutineRule.dispatcher) { + assertEquals( + "error_title_imageNotAllowed", + failUploadWith(PhotoSelectionViewModel.Slot.Avatar, rejection(RejectionReason.UNKNOWN)), + ) + } } From ca21405dcfd37380016ff16c3d8986c8d5b73cf6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 22:19:17 -0400 Subject: [PATCH 7/9] fix(user-profile): lay out the bio editor like iOS Match EditBioScreen.swift: a centred title, the field 20dp under the bar in textLarge (iOS appDisplayXS, 20pt bold), the counter directly under the field with any error below it, and Save 20dp off the bottom. The 8pt gaps round to the 5dp static grid. --- .../userprofile/internal/bio/EditBioScreen.kt | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioScreen.kt index acec612dbf..9d7ec40231 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioScreen.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -48,6 +49,7 @@ internal fun EditBioScreen() { Column { AppBarWithTitle( title = stringResource(R.string.title_bio), + titleAlignment = Alignment.CenterHorizontally, onBackIconClicked = { keyboard.hideIfVisible { flowNavigator.back() } }, @@ -79,7 +81,7 @@ private fun EditBioScreenContent( .navigationBarsPadding() .padding( top = CodeTheme.dimens.grid.x6, - bottom = CodeTheme.dimens.grid.x3, + bottom = CodeTheme.dimens.staticGrid.x4, ).imePadding(), text = stringResource(R.string.action_save), enabled = draft.canSave && state.processingState.isIdle, @@ -92,14 +94,17 @@ private fun EditBioScreenContent( } ) { padding -> val focusRequester = remember { FocusRequester() } + // The counter sits right under the field and any error under the counter, as on iOS. Column( - modifier = Modifier.padding(padding), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + modifier = Modifier + .padding(padding) + .padding(top = CodeTheme.dimens.staticGrid.x4), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x2), ) { DisplayTextInput( state = state.fieldState, placeholder = stringResource(R.string.placeholder_bio), - style = CodeTheme.typography.textMedium.copy(color = CodeTheme.colors.textMain), + style = CodeTheme.typography.textLarge.copy(color = CodeTheme.colors.textMain), modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester), @@ -111,6 +116,14 @@ private fun EditBioScreenContent( ), ) + Text( + modifier = Modifier.fillMaxWidth(), + text = draft.remaining.toString(), + style = CodeTheme.typography.textSmall, + color = if (draft.remaining < 0) CodeTheme.colors.errorText else CodeTheme.colors.textSecondary, + textAlign = TextAlign.End, + ) + val error = draft.error if (error != null) { Text( @@ -124,14 +137,6 @@ private fun EditBioScreenContent( color = CodeTheme.colors.errorText, ) } - - Text( - modifier = Modifier.fillMaxWidth(), - text = draft.remaining.toString(), - style = CodeTheme.typography.textSmall, - color = if (draft.remaining < 0) CodeTheme.colors.errorText else CodeTheme.colors.textSecondary, - textAlign = TextAlign.End, - ) } RequestFocusWhenSettled(focusRequester) From 1b49348f0f54c4caee8e3fe2024ff4abeb527f6c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 22:38:34 -0400 Subject: [PATCH 8/9] fix(user-profile): match iOS on Edit Profile and the cover editor Checked against iOS EditProfileScreen.swift and ChangeCoverPictureScreen.swift: - The cover editor is titled "Cover", sits 20dp under the bar instead of centred, and uses the banner's placeholder fill with a secondary plus. - Replacing a cover saves without the "Change Cover?" prompt. iOS only confirms a replacement avatar, so the avatar keeps its prompt and the two unused cover prompt strings go. - Save sits 20dp from the bottom on both pickers, as on iOS. - Edit Profile's "Change cover" chip frosts the cover behind it, like iOS's glass CoverChip, and takes iOS's sentence case. - The camera badge sits 6dp right and 2dp down off the avatar, as iOS offsets it. - "Minimum to Chat" takes iOS's casing. --- .../core/src/main/res/values/strings.xml | 8 ++---- .../features/myaccount/build.gradle.kts | 1 + .../editprofile/EditProfileScreenContent.kt | 21 +++++++++++--- .../internal/photo/PhotoSelectionScreen.kt | 16 ++++++----- .../internal/photo/PhotoSelectionViewModel.kt | 18 ++++-------- .../photo/PhotoSelectionViewModelTest.kt | 28 +++++++++++++++++++ 6 files changed, 64 insertions(+), 28 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 3d65ff47a3..b8e131dfc7 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1251,10 +1251,8 @@ AI flagged this photo as misleading. Please try a different photo. If you think the photo was rejected in error please DM @flipcash on X AI flagged this photo as spam. Please try a different photo. If you think the photo was rejected in error please DM @flipcash on X Set Profile Picture - Set Cover - Change Cover? - This will change your cover - Change Cover + Cover + Change cover Couldn\'t Upload Your Cover This image isn\'t allowed as a cover. Please try a different image. If you think it was rejected in error please DM @flipcash on X Your Name @@ -1276,7 +1274,7 @@ starting a conversation. Distinct from the send-side strings above, which are about a tip in flight. The floor is explained in prose under the keypad and enforced by keeping the save button inert, so there is no below-minimum error to show. --> - Minimum To Chat + Minimum to Chat Choose how much someone must send you to start chatting. You can send it back if the conversation is worthwhile. Something Went Wrong We were unable to save your minimum tip. Please try again diff --git a/apps/flipcash/features/myaccount/build.gradle.kts b/apps/flipcash/features/myaccount/build.gradle.kts index e338e521d6..59f5a1d8fb 100644 --- a/apps/flipcash/features/myaccount/build.gradle.kts +++ b/apps/flipcash/features/myaccount/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { testImplementation(libs.bundles.compose.ui.testing) testImplementation(testFixtures(project(":ui:resources"))) + implementation(libs.bundles.haze) implementation(libs.compose.paging) implementation(project(":apps:flipcash:shared:analytics")) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileScreenContent.kt index 538bbf6651..5527f290f6 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileScreenContent.kt @@ -37,12 +37,18 @@ import com.getcode.theme.CodeTheme import com.getcode.theme.White05 import com.getcode.ui.components.ListItemDefaults import androidx.compose.ui.unit.Dp +import androidx.compose.foundation.layout.offset +import com.getcode.ui.components.glass.floatingGlass +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState // Carried from the iOS screen. private val CoverHeight = 126.dp private val AvatarSize = 68.dp private val BadgeSize = 28.dp private val BadgeIconSize = 16.dp +private val BadgeOffsetX = 6.dp +private val BadgeOffsetY = 2.dp @Composable internal fun EditProfileScreenContent( @@ -116,6 +122,8 @@ private fun CoverAndPhoto( onChangePhoto: () -> Unit, ) { val grid = CodeTheme.dimens.staticGrid + // The cover is what the Change cover chip frosts, as iOS's CoverChip is glass over it. + val hazeState = rememberHazeState() Box(modifier = Modifier.fillMaxWidth().height(CoverHeight + AvatarSize / 2)) { Box( modifier = Modifier @@ -128,7 +136,9 @@ private fun CoverAndPhoto( image = state.cover, access = BlobAccessContext.Owned, height = Dp.Unspecified, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .hazeSource(hazeState), ) Text( text = stringResource(R.string.action_changeCover), @@ -137,8 +147,7 @@ private fun CoverAndPhoto( modifier = Modifier .align(Alignment.TopEnd) .padding(grid.x2) - .clip(CircleShape) - .background(CodeTheme.colors.background) + .floatingGlass(hazeState) .padding(horizontal = grid.x2, vertical = grid.x1), ) } @@ -161,7 +170,11 @@ private fun CoverAndPhoto( .clip(CircleShape) .border(grid.x1, CodeTheme.colors.background, CircleShape), ) - CameraBadge(modifier = Modifier.align(Alignment.BottomEnd)) + CameraBadge( + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = BadgeOffsetX, y = BadgeOffsetY), + ) } Text( text = stringResource(R.string.action_changePhoto), 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 ddba51eeaa..3e5adcb334 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 @@ -75,7 +75,7 @@ internal fun PhotoSelectionScreen(slot: PhotoSelectionViewModel.Slot = PhotoSele Column { AppBarWithTitle( title = stringResource( - if (slot == PhotoSelectionViewModel.Slot.Cover) R.string.title_setCover else R.string.title_setProfilePicture + if (slot == PhotoSelectionViewModel.Slot.Cover) R.string.title_cover else R.string.title_setProfilePicture ), titleAlignment = Alignment.CenterHorizontally, onBackIconClicked = { @@ -127,7 +127,7 @@ private fun PhotoSelectionScreenContent( modifier = Modifier .fillMaxWidth() .navigationBarsPadding() - .padding(bottom = CodeTheme.dimens.grid.x3), + .padding(bottom = CodeTheme.dimens.staticGrid.x4), text = stringResource(R.string.action_save), enabled = state.isChanged && state.processingState.isIdle, isLoading = state.processingState.loading, @@ -139,11 +139,14 @@ private fun PhotoSelectionScreenContent( } } ) { padding -> + val isCover = state.slot == PhotoSelectionViewModel.Slot.Cover + // The cover sits at the top like iOS's ChangeCoverPictureScreen; the avatar stays centred. Box( modifier = Modifier .fillMaxSize() - .padding(padding), - contentAlignment = Alignment.Center + .padding(padding) + .padding(top = if (isCover) CodeTheme.dimens.staticGrid.x4 else 0.dp), + contentAlignment = if (isCover) Alignment.TopCenter else Alignment.Center, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -151,7 +154,6 @@ private fun PhotoSelectionScreenContent( ) { // The avatar is a circle that shares its bounds with the name step; the cover is // previewed at the banner's own aspect so the crop matches what the profile shows. - val isCover = state.slot == PhotoSelectionViewModel.Slot.Cover val pictureShape = if (isCover) CodeTheme.shapes.medium else CircleShape Box( modifier = (if (isCover) { @@ -166,7 +168,7 @@ private fun PhotoSelectionScreenContent( ) }) .background( - color = CodeTheme.colors.divider, + color = if (isCover) CodeTheme.colors.surfaceVariant else CodeTheme.colors.divider, shape = pictureShape, ).clip(pictureShape) .clickable { @@ -219,7 +221,7 @@ private fun PhotoSelectionScreenContent( Icon( imageVector = Icons.Default.Add, contentDescription = null, - tint = White50, + tint = if (isCover) CodeTheme.colors.textSecondary else White50, modifier = Modifier.size(48.dp), ) } 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 1f03a1ca5c..0b4f1d3ed4 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 @@ -154,23 +154,17 @@ class PhotoSelectionViewModel @AssistedInject constructor( eventFlow .filterIsInstance() .onEach { - if (stateFlow.value.savedPicture == null) { + // Only a replacement avatar is confirmed, as on iOS: a first picture gives nothing + // up, and the cover saves straight away like ChangeCoverPictureScreen. + if (slot == Slot.Cover || stateFlow.value.savedPicture == null) { dispatchEvent(Event.CheckImage) return@onEach } BottomBarManager.showMessage( - title = resources.getString( - if (slot == Slot.Cover) R.string.prompt_title_changeCover else R.string.prompt_title_changeProfilePicture - ), - message = resources.getString( - if (slot == Slot.Cover) R.string.prompt_description_changeCover else R.string.prompt_description_changeProfilePicture - ), + title = resources.getString(R.string.prompt_title_changeProfilePicture), + message = resources.getString(R.string.prompt_description_changeProfilePicture), actions = listOf( - BottomBarAction( - resources.getString( - if (slot == Slot.Cover) R.string.action_changeCover else R.string.action_changeProfilePicture - ) - ) { + BottomBarAction(resources.getString(R.string.action_changeProfilePicture)) { dispatchEvent(Event.CheckImage) } ), diff --git a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModelTest.kt b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModelTest.kt index 48884642d4..b11b729b67 100644 --- a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModelTest.kt +++ b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModelTest.kt @@ -162,6 +162,34 @@ class PhotoSelectionViewModelTest { verify(profileController, never()).setCoverPicture(any()) } + @Test + fun `replacing a cover saves without asking first, as iOS does`() = runTest(mainCoroutineRule.dispatcher) { + stubUpload() + whenever(profileController.setCoverPicture(any())).thenReturn(Result.success(cover)) + val vm = viewModel(PhotoSelectionViewModel.Slot.Cover) + advanceUntilIdle() + + vm.dispatchEvent(PhotoSelectionViewModel.Event.OnImageCached(pick, "image/jpeg")) + vm.dispatchEvent(PhotoSelectionViewModel.Event.ConfirmImageChange) + advanceUntilIdle() + + assertEquals(null, BottomBarManager.messages.value.firstOrNull()) + verify(profileController).setCoverPicture(blobId) + } + + @Test + fun `replacing an avatar asks first`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel(PhotoSelectionViewModel.Slot.Avatar) + advanceUntilIdle() + + vm.dispatchEvent(PhotoSelectionViewModel.Event.OnImageCached(pick, "image/jpeg")) + vm.dispatchEvent(PhotoSelectionViewModel.Event.ConfirmImageChange) + advanceUntilIdle() + + assertEquals("prompt_title_changeProfilePicture", BottomBarManager.messages.value.firstOrNull()?.title) + verify(profileController, never()).setProfilePicture(any()) + } + private fun rejection(reason: RejectionReason, category: ModerationResult.FlaggedCategory = ModerationResult.FlaggedCategory.NONE) = BlobRejectedException(BlobRejection(reason, category)) From da93a9073770566a574d9426b09da82ad203c940 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 23:02:13 -0400 Subject: [PATCH 9/9] fix(menu): open Edit Profile from an unnamed account's header, as iOS does --- .../kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt index 7f2c265658..773af40b5a 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt @@ -115,7 +115,7 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { item(key = "profile_header") { OwnProfileHeader( profile = profileState.profile, - onEdit = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) }, + onEdit = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.EditProfile)) }, onShare = null, onClaimUsername = state.usernameProgress?.let { { viewModel.dispatchEvent(Event.ClaimUsername) }