diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/AbbreviatedLink.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/AbbreviatedLink.kt similarity index 93% rename from apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/AbbreviatedLink.kt rename to apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/AbbreviatedLink.kt index c43c6917e9..e85e40d73a 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/AbbreviatedLink.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/AbbreviatedLink.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.menu.internal +package com.flipcash.app.core.util import com.flipcash.services.models.isUsernameShaped @@ -17,7 +17,7 @@ private const val ABBREVIATED_ID_LENGTH = 5 * * Mirrors iOS `TipCardLinkRow.displayText(for:)`. */ -internal fun String.abbreviatedLink(): String { +fun String.abbreviatedLink(): String { val withoutScheme = substringAfter("://") val lastSegment = withoutScheme.substringAfterLast('/') if (lastSegment.isUsernameShaped()) return withoutScheme diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/AbbreviatedLinkTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/AbbreviatedLinkTest.kt similarity index 97% rename from apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/AbbreviatedLinkTest.kt rename to apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/AbbreviatedLinkTest.kt index 2cfc64e0e3..4591366663 100644 --- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/AbbreviatedLinkTest.kt +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/AbbreviatedLinkTest.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.menu.internal +package com.flipcash.app.core.util import kotlin.test.Test import kotlin.test.assertEquals 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 3862a6d6dd..5471b70542 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 @@ -80,6 +80,7 @@ import com.flipcash.app.menu.internal.MenuScreenViewModel.Event import com.flipcash.app.menu.internal.MenuScreenViewModel.TipCardState import com.flipcash.app.core.ui.onboarding.NewUserTutorial import com.flipcash.app.core.ui.onboarding.TutorialItem +import com.flipcash.app.core.util.abbreviatedLink import com.flipcash.app.menu.internal.components.UsernameProgress import com.flipcash.app.menu.internal.components.UsernameProgressCard import com.flipcash.app.theme.FlipcashThemeWrapper diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt index 9315966d27..9bfde89319 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt @@ -7,14 +7,11 @@ 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.core.DisplayNameSource import com.flipcash.app.myaccount.internal.userprofile.UserProfileScreenContent import com.flipcash.app.myaccount.internal.userprofile.UserProfileViewModel -import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle @@ -32,48 +29,14 @@ fun UserProfileScreen() { modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { + // No title: the header below names the account, so a bar title would say it twice. AppBarWithTitle( - title = { - AppBarDefaults.Title( - text = stringResource(R.string.title_userProfile), - ) - }, - titleAlignment = Alignment.CenterHorizontally, + title = { }, leftIcon = { AppBarDefaults.UpNavigation { navigator.pop() } }, ) UserProfileScreenContent(state = state, dispatch = viewModel::dispatchEvent) } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.push( - AppRoute.UpdateUserProfile( - origin = AppRoute.Menu.UserProfile, - nameSource = DisplayNameSource.MyAccount, - includeName = true, - includePhoto = false, - ) - ) - }.launchIn(this) - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.push( - AppRoute.UpdateUserProfile( - origin = AppRoute.Menu.UserProfile, - nameSource = DisplayNameSource.MyAccount, - includeName = false, - includePhoto = true, - ) - ) - }.launchIn(this) - } - LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt index 065e287310..b614fc2f7f 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt @@ -20,13 +20,15 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import javax.inject.Inject +// Render order. User Profile leads it, so the account being described comes before the settings +// that change it. private val FullMenuList = buildList { + add(UserProfile) add(ChangeDisplayName) add(ChangeUsername) add(ProfilePicture) add(RequireBiometrics) add(Blocklist) - add(UserProfile) } @HiltViewModel diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileScreenContent.kt index 356f50ea01..2bf81fac49 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileScreenContent.kt @@ -8,22 +8,25 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight 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.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -50,6 +53,20 @@ import com.getcode.theme.CodeTheme import com.getcode.ui.components.SwipeAction import com.getcode.ui.components.SwipeActionRow import com.getcode.ui.components.text.SectionHeader +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.tween +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.sp +import com.flipcash.app.core.util.abbreviatedLink +import com.getcode.theme.White05 +import com.getcode.theme.extraSmall +import com.getcode.ui.core.verticalScrollStateGradient +import kotlinx.coroutines.delay @Composable internal fun UserProfileScreenContent( @@ -57,17 +74,40 @@ internal fun UserProfileScreenContent( dispatch: (UserProfileViewModel.Event) -> Unit, ) { val inset = CodeTheme.dimens.inset + val listState = rememberLazyListState() + // The gesture-bar inset rides in contentPadding rather than on the list itself: as a layout + // inset it shortened the viewport and cut the last card off at the padded edge, and it left the + // list exactly one screen tall and so unable to scroll at all. As content padding the list still + // fills the window and the last card scrolls clear of the bar, fading out under the gradient. + val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = inset), + modifier = Modifier + .fillMaxSize() + // Fades at both edges, so content reads as running past the app bar and the gesture bar + // rather than stopping at them. + .verticalScrollStateGradient( + scrollState = listState, + showAtStart = true, + showAtEnd = true, + isLongGradient = true, + ), + state = listState, + contentPadding = PaddingValues( + start = inset, + end = inset, + bottom = bottomInset + CodeTheme.dimens.grid.x3, + ), ) { - // Profile header — avatar (tap to edit photo) + name with a pencil (tap to edit name). + // Profile header — the account's public identity, read only. Editing the photo and the + // display name lives in My Account, which is the single place those are changed. item(contentType = "profile_header") { ProfileHeader( displayName = state.displayName, profilePicture = state.profilePicture, - onEditName = { dispatch(UserProfileViewModel.Event.EditNameClicked) }, - onEditPhoto = { dispatch(UserProfileViewModel.Event.EditPhotoClicked) }, + username = state.username, + tipCardLink = state.tipCardLink, + usernameMinBalance = state.usernameMinBalance, + onCopyLink = { dispatch(UserProfileViewModel.Event.CopyTipCardLink) }, ) } @@ -267,45 +307,136 @@ internal fun UserProfileScreenContent( private fun ProfileHeader( displayName: String, profilePicture: MediaItem?, - onEditName: () -> Unit, - onEditPhoto: () -> Unit, + username: String?, + tipCardLink: String?, + usernameMinBalance: String, + onCopyLink: () -> Unit, ) { Column( modifier = Modifier .fillMaxWidth() .padding(vertical = CodeTheme.dimens.grid.x6), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + // Avatar, name, handle and link are one identity block, so they sit tight together. + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), ) { ContactAvatar( image = profilePicture, - displayName = displayName.orEmpty(), + displayName = displayName, modifier = Modifier .size(96.dp) - .clip(CircleShape) - .clickable { onEditPhoto() }, + .clip(CircleShape), ) - Row( - modifier = Modifier.clickable { onEditName() }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), - ) { + Text( + text = displayName.takeIf { it.isNotEmpty() } + ?: stringResource(R.string.subtitle_noDisplayName), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + if (username != null) { Text( - text = displayName.takeIf { it.isNotEmpty() } - ?: stringResource(R.string.subtitle_noDisplayName), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, + text = "@$username", + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, ) + } else { + // The same flag the entry screen's rejection dialog quotes, so the two never name + // different thresholds. Stated rather than actionable: claiming happens on the You tab. + Text( + text = stringResource(R.string.title_usernameUpsell), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + ) + Text( + text = stringResource( + R.string.subtitle_usernameUpsellLocked, + usernameMinBalance, + ), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + } + if (tipCardLink != null) { + TipCardLinkRow( + link = tipCardLink, + onCopy = onCopyLink, + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + ) + } + } +} + +/** + * The account's public link, tap to copy — the same row the You tab shows, so the link reads and + * behaves identically in both places. The clipboard gives no feedback of its own, so the copy glyph + * holds a checkmark for a moment after the tap. + */ +@Composable +private fun TipCardLinkRow( + link: String, + onCopy: () -> Unit, + modifier: Modifier = Modifier, +) { + // Bumped rather than latched so a second tap restarts the hold instead of being swallowed. + var copyToken by remember { mutableIntStateOf(0) } + val copied = copyToken > 0 + + LaunchedEffect(copyToken) { + if (copyToken > 0) { + delay(CopyConfirmationMillis) + copyToken = 0 + } + } + + Row( + modifier = modifier + .height(CodeTheme.dimens.grid.x8) + .clip(CodeTheme.shapes.extraSmall) + .background(White05) + .clickable { + onCopy() + copyToken++ + } + .padding(horizontal = CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.ic_chain_link), + contentDescription = null, + tint = Color.White, + ) + Text( + text = link.abbreviatedLink(), + style = CodeTheme.typography.textSmall.copy(fontSize = 15.sp), + color = Color.White.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Crossfade( + targetState = copied, + animationSpec = tween(CopyIconFadeMillis, easing = EaseInOut), + label = "copyConfirmation", + ) { showCheck -> Icon( - imageVector = Icons.Default.Edit, + modifier = Modifier.size(20.dp), + painter = painterResource( + if (showCheck) R.drawable.ic_check_circle else R.drawable.ic_copy + ), contentDescription = null, - tint = CodeTheme.colors.textSecondary, - modifier = Modifier.size(16.dp), + tint = Color.White, ) } } } +/** How long the copy button holds the checkmark before reverting (iOS: 1.5s). */ +private const val CopyConfirmationMillis = 1_500L + +/** Cross-fade between the copy and confirmation glyphs (iOS: 0.15s ease-in-out). */ +private const val CopyIconFadeMillis = 150 + /** Non-swipeable card wrapper — just visual styling. */ @Composable private fun CardRow( diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileViewModel.kt index 995fd573c3..addae848f3 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileViewModel.kt @@ -4,6 +4,9 @@ import android.content.ClipboardManager import androidx.lifecycle.viewModelScope import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.extensions.setText +import com.flipcash.app.core.tipping.TipCardOwner +import com.flipcash.app.core.util.Linkify +import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.core.R import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.controllers.ContactVerificationController @@ -16,6 +19,7 @@ import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.model.core.uuid +import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.base58 import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel @@ -36,6 +40,7 @@ internal class UserProfileViewModel @Inject constructor( private val contactController: ContactVerificationController, private val contactCoordinator: ContactCoordinator, private val profileController: ProfileController, + private val userFlags: UserFlagsCoordinator, private val resources: ResourceHelper, clipboardManager: ClipboardManager, dispatchers: DispatcherProvider, @@ -47,6 +52,15 @@ internal class UserProfileViewModel @Inject constructor( internal data class State( val displayName: String = "", val profilePicture: MediaItem? = null, + /** The claimed handle, or null for an account that hasn't claimed one. */ + val username: String? = null, + /** + * The account's public link — `flipcash.com/` once a handle exists, and the UUID + * form until then. Null only before the account id has loaded. + */ + val tipCardLink: String? = null, + /** The `usernameMinBalance` flag, formatted, for the account that has no handle yet. */ + val usernameMinBalance: String = "", val phone: VerifiableContactMethod? = null, val email: VerifiableContactMethod? = null, val phoneLinkedForPayment: Boolean = false, @@ -60,18 +74,15 @@ internal class UserProfileViewModel @Inject constructor( data class OnProfileUpdated( val displayName: String, val profilePicture: MediaItem?, + val username: String?, + val tipCardLink: String?, + val usernameMinBalance: String, val phone: VerifiableContactMethod?, val email: VerifiableContactMethod?, val linkedForPayment: Boolean, val socialAccounts: List, ) : Event - /** Open the update-profile flow to set/replace the display name. */ - data object EditNameClicked : Event - - /** Open the update-profile flow to set/replace the profile picture. */ - data object EditPhotoClicked : Event - data object UnlinkPhoneClicked : Event data object UnlinkEmailClicked : Event data object ConnectPhoneClicked : Event @@ -91,19 +102,36 @@ internal class UserProfileViewModel @Inject constructor( data object CopyPublicKey : Event data object CopyAccountId : Event data object CopyPushToken : Event + + /** Put [State.tipCardLink] on the clipboard. */ + data object CopyTipCardLink : Event } init { combine( userManager.state, contactCoordinator.isLinkedForPayment, - ) { state, linkedForPayment -> + userFlags.resolvedFlags, + ) { state, linkedForPayment, flags -> val profile = state.userProfile + val minimum = flags.usernameMinBalance.effectiveValue dispatchEvent( Event.OnProfileUpdated( displayName = profile?.displayName.orEmpty(), profilePicture = profile?.profilePicture, + username = profile?.username, + // Built the same way the You tab builds it, so the two never show different + // links for the same account: the handle when there is one, the id until then. + tipCardLink = state.accountId?.let { userId -> + Linkify.tipcard( + TipCardOwner.preferringUsername(profile?.username, userId) + ) + }, + usernameMinBalance = minimum.formatted( + rule = Fiat.FormattingRule.Truncated, + suffix = minimum.currencyCode.name, + ), // Carry the contact (value + verified) so unverified entries still show. phone = profile?.phoneNumber, email = profile?.email, @@ -227,6 +255,16 @@ internal class UserProfileViewModel @Inject constructor( label = resources.getString(R.string.title_clipboardLabelPushToken) ) }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { stateFlow.value.tipCardLink } + .onEach { + clipboardManager.setText( + text = it, + label = resources.getString(R.string.title_clipboardLabelTipCardLink) + ) + }.launchIn(viewModelScope) } private suspend fun unlinkPhone() { @@ -283,6 +321,9 @@ internal class UserProfileViewModel @Inject constructor( state.copy( displayName = event.displayName, profilePicture = event.profilePicture, + username = event.username, + tipCardLink = event.tipCardLink, + usernameMinBalance = event.usernameMinBalance, phone = event.phone, email = event.email, phoneLinkedForPayment = event.linkedForPayment, @@ -304,14 +345,13 @@ internal class UserProfileViewModel @Inject constructor( Event.ConnectEmailClicked, Event.ReplacePhoneClicked, Event.ReplaceEmailClicked, - Event.EditNameClicked, - Event.EditPhotoClicked, is Event.UnlinkSocialAccountClicked, Event.NavigateToPhoneVerification, Event.NavigateToEmailVerification, Event.CopyPublicKey, Event.CopyAccountId, - Event.CopyPushToken -> { state -> state } + Event.CopyPushToken, + Event.CopyTipCardLink -> { state -> state } } } } diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt index 16f1eccc93..b96631cc53 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt @@ -41,6 +41,9 @@ class ContactMethodsViewModelStateTest { UserProfileViewModel.Event.OnProfileUpdated( displayName = "Alice", profilePicture = null, + username = "alice", + tipCardLink = "https://flipcash.com/alice", + usernameMinBalance = "$5 USD", phone = VerifiableContactMethod("+15551234567", verified = true), email = VerifiableContactMethod("test@example.com", verified = false), linkedForPayment = true, @@ -48,6 +51,8 @@ class ContactMethodsViewModelStateTest { ) )(UserProfileViewModel.State()) assertEquals("Alice", updated.displayName) + assertEquals("alice", updated.username) + assertEquals("https://flipcash.com/alice", updated.tipCardLink) assertEquals(VerifiableContactMethod("+15551234567", verified = true), updated.phone) assertEquals(VerifiableContactMethod("test@example.com", verified = false), updated.email) assertTrue(updated.phoneLinkedForPayment) @@ -61,6 +66,9 @@ class ContactMethodsViewModelStateTest { UserProfileViewModel.Event.OnProfileUpdated( displayName = "", profilePicture = null, + username = null, + tipCardLink = null, + usernameMinBalance = "$5 USD", phone = null, email = null, linkedForPayment = false, @@ -68,6 +76,10 @@ class ContactMethodsViewModelStateTest { ) )(UserProfileViewModel.State()) assertTrue(updated.displayName.isEmpty()) + // No handle claimed: the header falls back to the upsell, which needs the minimum. + assertNull(updated.username) + assertNull(updated.tipCardLink) + assertEquals("$5 USD", updated.usernameMinBalance) assertNull(updated.phone) assertNull(updated.email) assertFalse(updated.phoneLinkedForPayment) diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt index 8b2aa5ecaa..f815300f4b 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt @@ -153,6 +153,25 @@ class MyAccountScreenViewModelStateTest { assertTrue(updated.biometricsAvailable) } + @Test + fun `an unlocked user profile leads the list`() { + val unlocked = reduce( + MyAccountScreenViewModel.Event.OnBetaFeaturesUnlocked(unlocked = true) + )(claimed(MyAccountScreenViewModel.State())) + + assertEquals( + listOf( + UserProfile, + ChangeDisplayName, + ChangeUsername, + ProfilePicture, + RequireBiometrics, + Blocklist, + ), + unlocked.items, + ) + } + @Test fun `user profile stays hidden until beta features unlock`() { val locked = MyAccountScreenViewModel.State() diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContentTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContentTest.kt index 7d214d1a16..a5e01dc024 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContentTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContentTest.kt @@ -1,10 +1,13 @@ package com.flipcash.app.myaccount.internal import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode import com.flipcash.app.myaccount.internal.userprofile.UserProfileScreenContent import com.flipcash.app.myaccount.internal.userprofile.UserProfileViewModel import com.flipcash.services.models.SocialAccount @@ -24,6 +27,11 @@ class UserProfileScreenContentTest { private var lastEvent: UserProfileViewModel.Event? = null + /** The social section sits below the fold, so bring it into view before asserting on it. */ + private fun scrollTo(text: String) { + composeTestRule.onNode(hasScrollAction()).performScrollToNode(hasText(text)) + } + private fun setScreen(state: UserProfileViewModel.State = UserProfileViewModel.State()) { lastEvent = null composeTestRule.setContent { @@ -49,6 +57,43 @@ class UserProfileScreenContentTest { composeTestRule.onNodeWithText("No display name set").assertIsDisplayed() } + // --------------------------------------------------------------- + // Handle and public link + // --------------------------------------------------------------- + + @Test + fun `claimed handle shown with its link`() { + setScreen( + UserProfileViewModel.State( + username = "alice", + tipCardLink = "https://flipcash.com/alice", + ) + ) + composeTestRule.onNodeWithText("@alice").assertIsDisplayed() + // The scheme is dropped: the link is shown the way it's read aloud. + composeTestRule.onNodeWithText("flipcash.com/alice").assertIsDisplayed() + } + + @Test + fun `unclaimed handle shows the upsell and its minimum`() { + setScreen( + UserProfileViewModel.State( + username = null, + usernameMinBalance = "$5 USD", + ) + ) + composeTestRule.onNodeWithText("Get a custom @username").assertIsDisplayed() + composeTestRule.onNodeWithText("Get your balance to $5 USD or more to unlock") + .assertIsDisplayed() + } + + @Test + fun `tapping the link dispatches CopyTipCardLink`() { + setScreen(UserProfileViewModel.State(tipCardLink = "https://flipcash.com/alice")) + composeTestRule.onNodeWithText("flipcash.com/alice").performClick() + assertTrue(lastEvent is UserProfileViewModel.Event.CopyTipCardLink) + } + // --------------------------------------------------------------- // Phone // --------------------------------------------------------------- @@ -132,6 +177,7 @@ class UserProfileScreenContentTest { followerCount = 0, ) setScreen(UserProfileViewModel.State(socialAccounts = listOf(account))) + scrollTo("@testuser") composeTestRule.onNodeWithText("@testuser").assertIsDisplayed() composeTestRule.onNodeWithText("Test User").assertIsDisplayed() } @@ -139,6 +185,7 @@ class UserProfileScreenContentTest { @Test fun `no social accounts placeholder when list empty`() { setScreen(UserProfileViewModel.State(socialAccounts = emptyList())) + scrollTo("No social accounts linked") composeTestRule.onNodeWithText("No social accounts linked").assertIsDisplayed() }