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 ab0a4e1f7..4f766d7b3 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 @@ -37,6 +37,7 @@ import com.flipcash.app.lab.LabsScreen import com.flipcash.app.lab.NavBarSettingsScreen import com.flipcash.app.login.OnboardingFlowScreen import com.flipcash.app.menu.MenuScreen +import com.flipcash.app.myaccount.UserProfileScreen import com.flipcash.app.myaccount.MyAccountScreen import com.flipcash.app.scanner.ScannerScreen import com.flipcash.app.shareapp.ShareAppScreen @@ -117,6 +118,7 @@ fun appEntryProvider( annotatedEntry { AppSettingsScreen() } annotatedEntry { LabsScreen() } annotatedEntry { NavBarSettingsScreen() } + annotatedEntry { UserProfileScreen() } annotatedEntry { MyAccountScreen() } annotatedEntry { BackupKeyScreen() } annotatedEntry { AdvancedFeaturesScreen() } 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 e1dbdfbea..98f26726d 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 @@ -236,6 +236,8 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data object DeviceLogs : Menu @Serializable + data object UserProfile : Menu + @Serializable data object Lab : Menu @Serializable data object NavBarSettings : Menu, com.getcode.navigation.Sheet, com.getcode.navigation.WrapContentSheet diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 749f68dcb..8599d00d3 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -324,6 +324,24 @@ Unlink Phone Unlink Email + User Profile + Phone Number + Email Address + Add Phone Number + Add Email Address + Linked for payments + Unlink Phone Number? + Your phone number will be removed from your profile. + Unlink Email Address? + Your email address will be removed from your profile. + Display Name + Social Accounts + No display name set + No social accounts linked + Unlink Account? + This social account will be removed from your profile. + Unlink Account + Verify Your Phone Number And Email To Continue This will allow you to add funds from your debit card " diff --git a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt index edd8626ba..8e6955d0b 100644 --- a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt +++ b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt @@ -10,9 +10,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MarkEmailUnread -import androidx.compose.material.icons.filled.Navigation -import androidx.compose.material.icons.filled.PhonelinkErase +import androidx.compose.material.icons.filled.ContactMail import androidx.compose.material.icons.filled.Token import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text @@ -45,7 +43,6 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel) { val betaFlagsController = LocalFeatureFlags.current val betaFlags by betaFlagsController.observe().collectAsStateWithLifecycle() val navigator = LocalCodeNavigator.current - val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle() val isStaff by viewModel.isStaff.collectAsStateWithLifecycle() val state = rememberLazyListState() @@ -137,26 +134,6 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel) { } } } - - if (isLoggedIn) { - item { SectionHeader(stringResource(R.string.title_settingsSectionAccount)) } - item { - ListItem( - headline = stringResource(R.string.action_unlinkPhone), - icon = rememberVectorPainter(Icons.Default.PhonelinkErase), - ) { - viewModel.unlinkPhone() - } - } - item { - ListItem( - headline = stringResource(R.string.action_unlinkEmail), - icon = rememberVectorPainter(Icons.Default.MarkEmailUnread), - ) { - viewModel.unlinkEmail() - } - } - } } } diff --git a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenViewModel.kt b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenViewModel.kt index 0aeb1974c..cc8603bd3 100644 --- a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenViewModel.kt +++ b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenViewModel.kt @@ -3,31 +3,19 @@ package com.flipcash.app.lab.internal import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flipcash.app.userflags.UserFlagsCoordinator -import com.flipcash.features.lab.R -import com.flipcash.services.controllers.ContactVerificationController -import com.flipcash.services.models.UserFlags -import com.flipcash.services.models.ContactMethod -import com.flipcash.services.models.EmailVerificationError -import com.flipcash.services.models.PhoneVerificationError import com.flipcash.services.user.AuthState import com.flipcash.services.user.UserManager -import com.getcode.manager.BottomBarAction -import com.getcode.manager.BottomBarManager -import com.getcode.util.resources.ResourceHelper import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class LabsScreenViewModel @Inject constructor( - private val userManager: UserManager, + userManager: UserManager, userFlags: UserFlagsCoordinator, - private val contactController: ContactVerificationController, - private val resources: ResourceHelper, ) : ViewModel() { val isLoggedIn = userManager @@ -38,52 +26,4 @@ class LabsScreenViewModel @Inject constructor( val isStaff = userFlags.resolvedFlags.map { it.isStaff.effectiveValue } .stateIn(viewModelScope, started = SharingStarted.Eagerly, initialValue = false) - - fun unlinkEmail() = viewModelScope.launch { - val email = userManager.profile?.verifiedEmailAddress - if (email == null) { - BottomBarManager.showAlert( - title = resources.getString(R.string.error_title_failedToUnlinkEmail), - message = resources.getString(R.string.error_description_failedToUnlinkEmailNonePresent) - ) - return@launch - } - val method = ContactMethod.Email(email) - contactController.unlink(method) - .onFailure { - BottomBarManager.showError( - title = resources.getString(R.string.error_title_failedToUnlinkEmail), - message = resources.getString(R.string.error_description_failedToUnlinkEmail) - ) - }.onSuccess { - BottomBarManager.showSuccess( - title = resources.getString(R.string.prompt_title_emailUnlinked), - message = resources.getString(R.string.prompt_description_emailUnlinked), - ) - } - } - - fun unlinkPhone() = viewModelScope.launch { - val phone = userManager.profile?.verifiedPhoneNumber - if (phone == null) { - BottomBarManager.showAlert( - title = resources.getString(R.string.error_title_failedToUnlinkPhone), - message = resources.getString(R.string.error_description_failedToUnlinkPhoneNonePresent) - ) - return@launch - } - val method = ContactMethod.Phone(phone) - contactController.unlink(method) - .onFailure { - BottomBarManager.showError( - title = resources.getString(R.string.error_title_failedToUnlinkPhone), - message = resources.getString(R.string.error_description_failedToUnlinkPhone) - ) - }.onSuccess { - BottomBarManager.showSuccess( - title = resources.getString(R.string.prompt_title_phoneUnlinked), - message = resources.getString(R.string.prompt_description_phoneUnlinked), - ) - } - } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt index d1fa5f981..81414cfb8 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt @@ -77,30 +77,8 @@ fun MyAccountScreen() { LaunchedEffect(viewModel) { viewModel.eventFlow - .filterIsInstance() - .onEach { - val flow = AppRoute.Verification( - origin = AppRoute.Menu.MyAccount, - includePhone = true, - includeEmail = false, - linkForPayment = it.linkForPayment - ) - - navigator.push(flow) } - .launchIn(this) - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - val flow = AppRoute.Verification( - origin = AppRoute.Menu.MyAccount, - includePhone = false, - includeEmail = true, - ) - - navigator.push(flow) } + .filterIsInstance() + .onEach { navigator.push(AppRoute.Menu.UserProfile) } .launchIn(this) } } 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 new file mode 100644 index 000000000..620bd0bb2 --- /dev/null +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt @@ -0,0 +1,75 @@ +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.UserProfileScreenContent +import com.flipcash.app.myaccount.internal.UserProfileViewModel +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 UserProfileScreen() { + 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.title_userProfile), + ) + }, + isInModal = true, + titleAlignment = Alignment.CenterHorizontally, + leftIcon = { AppBarDefaults.UpNavigation { navigator.pop() } }, + ) + UserProfileScreenContent(state = state, dispatch = viewModel::dispatchEvent) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.push( + AppRoute.Verification( + origin = AppRoute.Menu.UserProfile, + includePhone = true, + includeEmail = false, + linkForPayment = state.phoneLinkedForPayment, + ) + ) + }.launchIn(this) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.push( + AppRoute.Verification( + origin = AppRoute.Menu.UserProfile, + includePhone = false, + includeEmail = true, + ) + ) + }.launchIn(this) + } +} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountMenuItems.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountMenuItems.kt index a859e4956..bc9a76ec8 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountMenuItems.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountMenuItems.kt @@ -1,8 +1,7 @@ package com.flipcash.app.myaccount.internal import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Email -import androidx.compose.material.icons.filled.Phone +import androidx.compose.material.icons.filled.ContactMail import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector @@ -10,6 +9,8 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource 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 @@ -21,20 +22,12 @@ internal data object AccessKey : FullMenuItem() override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnAccessKeyClicked } -internal data object VerifyEmail : FullMenuItem() { +internal data object UserProfile : StaffMenuItem() { override val icon: Painter - @Composable get() = rememberVectorPainter(Icons.Default.Email) + @Composable get() = rememberVectorPainter(Icons.Default.ContactMail) override val name: String - @Composable get() = stringResource(R.string.title_connectEmailAddress) - override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnVerifyEmailClicked -} - -internal data object VerifyPhone : FullMenuItem() { - override val icon: Painter - @Composable get() = rememberVectorPainter(Icons.Default.Phone) - override val name: String - @Composable get() = stringResource(R.string.title_connectPhoneNumber) - override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnVerifyPhoneClicked + @Composable get() = stringResource(CoreR.string.title_userProfile) + override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnContactMethodsClicked } internal data object LogOut : FullMenuItem() { diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModel.kt index 5b69179f6..c9acf1bfc 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModel.kt @@ -4,9 +4,9 @@ import android.content.ClipboardManager import androidx.lifecycle.viewModelScope import com.flipcash.app.auth.AuthManager import com.flipcash.app.core.extensions.setText -import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.menu.MenuItem +import com.flipcash.app.menu.StaffMenuItem import com.flipcash.features.myaccount.R import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.user.UserManager @@ -30,8 +30,7 @@ import javax.inject.Inject private val FullMenuList = buildList { add(AccessKey) - add(VerifyPhone) - add(VerifyEmail) + add(UserProfile) add(LogOut) add(DeleteAccount) } @@ -55,7 +54,6 @@ internal class MyAccountScreenViewModel @Inject constructor( val accountId: String? = null, val publicKey: String? = null, val pushToken: String? = null, - val linkForPayment: Boolean = false, val items: List> = FullMenuList ) @@ -64,7 +62,6 @@ internal class MyAccountScreenViewModel @Inject constructor( val userId: String?, val publicKey: String?, val pushToken: String? = null, - val linkForPayment: Boolean = false, ) : Event data class OnBetaFeaturesUnlocked(val unlocked: Boolean) : Event @@ -72,9 +69,8 @@ internal class MyAccountScreenViewModel @Inject constructor( data class ToggleAccountInfo(val show: Boolean) : Event data object OnAccessKeyClicked : Event data object OnViewAccessKey : Event - data object OnVerifyEmailClicked : Event - data object OnVerifyPhoneClicked : Event - data class ConnectPhoneClicked(val linkForPayment: Boolean) : Event + data object OnContactMethodsClicked : Event + data object OnViewUserProfile : Event data object OnDeleteAccountClicked : Event data object OnAccountDeleted : Event data object CopyPublicKey : Event @@ -85,26 +81,19 @@ internal class MyAccountScreenViewModel @Inject constructor( } init { - combine( - userManager.state, - featureFlagController.observe(FeatureFlag.PhoneNumberSend), - ) { state, sendEnabled -> - val userId = state.accountId?.base64 - val publicKey = state.cluster?.authorityPublicKey?.base58() - - val linkForPayment = sendEnabled || - state.flags?.enablePhoneNumberSend == true + userManager.state + .onEach { state -> + val userId = state.accountId?.base64 + val publicKey = state.cluster?.authorityPublicKey?.base58() - dispatchEvent( - Event.OnUserAssociated( - userId = userId, - publicKey = publicKey, - pushToken = state.pushToken, - linkForPayment = linkForPayment, + dispatchEvent( + Event.OnUserAssociated( + userId = userId, + publicKey = publicKey, + pushToken = state.pushToken, + ) ) - ) - - }.launchIn(viewModelScope) + }.launchIn(viewModelScope) combine( featureFlagController.observeOverride(), @@ -195,9 +184,9 @@ internal class MyAccountScreenViewModel @Inject constructor( }.launchIn(viewModelScope) eventFlow - .filterIsInstance() + .filterIsInstance() .onEach { - dispatchEvent(Event.ConnectPhoneClicked(stateFlow.value.linkForPayment)) + dispatchEvent(Event.OnViewUserProfile) }.launchIn(viewModelScope) eventFlow @@ -235,7 +224,7 @@ internal class MyAccountScreenViewModel @Inject constructor( return if (isBetaEnabled) { FullMenuList } else { - FullMenuList.filterNot { item -> item is VerifyEmail || item is VerifyPhone } + FullMenuList.filterNot { item -> item is StaffMenuItem } } } @@ -246,15 +235,13 @@ internal class MyAccountScreenViewModel @Inject constructor( accountId = event.userId, publicKey = event.publicKey, pushToken = event.pushToken, - linkForPayment = event.linkForPayment, ) } Event.OnLogOutClicked, Event.OnLoggedOutCompletely, - Event.OnVerifyPhoneClicked, - is Event.ConnectPhoneClicked, - Event.OnVerifyEmailClicked, + Event.OnContactMethodsClicked, + Event.OnViewUserProfile, Event.OnViewAccessKey, Event.CopyPublicKey, Event.CopyAccountId, diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt new file mode 100644 index 000000000..9e7890bb6 --- /dev/null +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt @@ -0,0 +1,247 @@ +package com.flipcash.app.myaccount.internal + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import com.flipcash.core.R +import com.flipcash.services.models.SocialAccount +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.text.SectionHeader +import com.getcode.ui.core.rememberedClickable + +@Composable +internal fun UserProfileScreenContent( + state: UserProfileViewModel.State, + dispatch: (UserProfileViewModel.Event) -> Unit, +) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + // Display Name section + item { SectionHeader(stringResource(R.string.title_sectionDisplayName)) } + item { + val name = state.displayName + if (!name.isNullOrEmpty()) { + ProfileValueRow(value = name) + } else { + Text( + text = stringResource(R.string.subtitle_noDisplayName), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + modifier = Modifier.padding( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.grid.x3, + ), + ) + } + } + + // Phone section + item { SectionHeader(stringResource(R.string.title_sectionPhone)) } + item { + if (state.phoneNumber != null) { + ContactMethodRow( + value = state.phoneNumber, + subtitle = if (state.phoneLinkedForPayment) { + stringResource(R.string.subtitle_linkedForPayments) + } else null, + onRowClick = { dispatch(UserProfileViewModel.Event.ReplacePhoneClicked) }, + onDeleteClick = { dispatch(UserProfileViewModel.Event.UnlinkPhoneClicked) }, + ) + } else { + AddContactMethodRow( + label = stringResource(R.string.action_addPhoneNumber), + onClick = { dispatch(UserProfileViewModel.Event.ConnectPhoneClicked) }, + ) + } + } + + // Email section + item { SectionHeader(stringResource(R.string.title_sectionEmail)) } + item { + if (state.emailAddress != null) { + ContactMethodRow( + value = state.emailAddress, + subtitle = null, + onRowClick = { dispatch(UserProfileViewModel.Event.ReplaceEmailClicked) }, + onDeleteClick = { dispatch(UserProfileViewModel.Event.UnlinkEmailClicked) }, + ) + } else { + AddContactMethodRow( + label = stringResource(R.string.action_addEmailAddress), + onClick = { dispatch(UserProfileViewModel.Event.ConnectEmailClicked) }, + ) + } + } + + // Social Accounts section + item { SectionHeader(stringResource(R.string.title_sectionSocialAccounts)) } + if (state.socialAccounts.isEmpty()) { + item { + Text( + text = stringResource(R.string.subtitle_noSocialAccounts), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + modifier = Modifier.padding( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.grid.x3, + ), + ) + } + } else { + items(state.socialAccounts, key = { (it as? SocialAccount.TwitterX)?.id ?: it }) { account -> + when (account) { + is SocialAccount.TwitterX -> SocialAccountRow( + account = account, + onDeleteClick = { + dispatch(UserProfileViewModel.Event.UnlinkSocialAccountClicked(account)) + }, + ) + } + } + } + } +} + +@Composable +private fun ProfileValueRow(value: String) { + Text( + text = value, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + modifier = Modifier.padding( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.grid.x3, + ), + ) +} + +@Composable +private fun ContactMethodRow( + value: String, + subtitle: String?, + onRowClick: () -> Unit, + onDeleteClick: () -> Unit, +) { + Row( + modifier = Modifier + .rememberedClickable { onRowClick() } + .fillMaxWidth() + .padding( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.grid.x3, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = value, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + if (subtitle != null) { + Text( + text = subtitle, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + } + IconButton(onClick = onDeleteClick) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5), + ) + } + } +} + +@Composable +private fun SocialAccountRow( + account: SocialAccount.TwitterX, + onDeleteClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.grid.x3, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "@${account.username}", + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + Text( + text = account.name, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + IconButton(onClick = onDeleteClick) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5), + ) + } + } +} + +@Composable +private fun AddContactMethodRow( + label: String, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .rememberedClickable { onClick() } + .fillMaxWidth() + .padding( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.grid.x3, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + modifier = Modifier + .size(CodeTheme.dimens.staticGrid.x5) + .padding(end = CodeTheme.dimens.grid.x2), + ) + Text( + text = label, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + ) + } +} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt new file mode 100644 index 000000000..08f6800c8 --- /dev/null +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt @@ -0,0 +1,224 @@ +package com.flipcash.app.myaccount.internal + +import androidx.lifecycle.viewModelScope +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.core.R +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.controllers.ContactVerificationController +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.ContactMethod +import com.flipcash.services.models.SocialAccount +import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class UserProfileViewModel @Inject constructor( + private val userManager: UserManager, + private val contactController: ContactVerificationController, + private val profileController: ProfileController, + featureFlagController: FeatureFlagController, + private val resources: ResourceHelper, + dispatchers: DispatcherProvider, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, +) { + internal data class State( + val displayName: String? = null, + val phoneNumber: String? = null, + val emailAddress: String? = null, + val phoneLinkedForPayment: Boolean = false, + val socialAccounts: List = emptyList(), + ) + + internal sealed interface Event { + data class OnProfileUpdated( + val displayName: String?, + val phone: String?, + val email: String?, + val linkedForPayment: Boolean, + val socialAccounts: List, + ) : Event + + data object UnlinkPhoneClicked : Event + data object UnlinkEmailClicked : Event + data object ConnectPhoneClicked : Event + data object ConnectEmailClicked : Event + data object ReplacePhoneClicked : Event + data object ReplaceEmailClicked : Event + data class UnlinkSocialAccountClicked(val account: SocialAccount.TwitterX) : Event + data object NavigateToPhoneVerification : Event + data object NavigateToEmailVerification : Event + } + + init { + combine( + userManager.state, + featureFlagController.observe(FeatureFlag.PhoneNumberSend), + ) { state, sendEnabled -> + val profile = state.userProfile + val linkedForPayment = sendEnabled || + state.flags?.enablePhoneNumberSend == true + + dispatchEvent( + Event.OnProfileUpdated( + displayName = profile?.displayName, + phone = profile?.verifiedPhoneNumber, + email = profile?.verifiedEmailAddress, + linkedForPayment = linkedForPayment, + socialAccounts = profile?.socialAccounts.orEmpty(), + ) + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_unlinkPhone), + message = resources.getString(R.string.prompt_description_unlinkPhone), + actions = listOf( + BottomBarAction(resources.getString(R.string.action_unlinkPhone)) { + viewModelScope.launch { + delay(150) + unlinkPhone() + } + } + ), + showCancel = true, + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_unlinkEmail), + message = resources.getString(R.string.prompt_description_unlinkEmail), + actions = listOf( + BottomBarAction(resources.getString(R.string.action_unlinkEmail)) { + viewModelScope.launch { + delay(150) + unlinkEmail() + } + } + ), + showCancel = true, + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { event -> + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_unlinkSocialAccount), + message = resources.getString(R.string.prompt_description_unlinkSocialAccount), + actions = listOf( + BottomBarAction(resources.getString(R.string.action_unlinkAccount)) { + viewModelScope.launch { + delay(150) + unlinkSocialAccount(event.account) + } + } + ), + showCancel = true, + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { dispatchEvent(Event.NavigateToPhoneVerification) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { dispatchEvent(Event.NavigateToPhoneVerification) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { dispatchEvent(Event.NavigateToEmailVerification) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { dispatchEvent(Event.NavigateToEmailVerification) } + .launchIn(viewModelScope) + } + + private suspend fun unlinkPhone() { + val phone = userManager.profile?.verifiedPhoneNumber ?: return + contactController.unlink(ContactMethod.Phone(phone)) + .onFailure { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToUnlinkPhone), + message = resources.getString(R.string.error_description_failedToUnlinkPhone), + ) + }.onSuccess { + BottomBarManager.showSuccess( + title = resources.getString(R.string.prompt_title_phoneUnlinked), + message = resources.getString(R.string.prompt_description_phoneUnlinked), + ) + } + } + + private suspend fun unlinkEmail() { + val email = userManager.profile?.verifiedEmailAddress ?: return + contactController.unlink(ContactMethod.Email(email)) + .onFailure { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToUnlinkEmail), + message = resources.getString(R.string.error_description_failedToUnlinkEmail), + ) + }.onSuccess { + BottomBarManager.showSuccess( + title = resources.getString(R.string.prompt_title_emailUnlinked), + message = resources.getString(R.string.prompt_description_emailUnlinked), + ) + } + } + + private suspend fun unlinkSocialAccount(account: SocialAccount.TwitterX) { + profileController.unlinkTwitterXAccount(account) + .onSuccess { profileController.updateUserProfile() } + } + + internal companion object { + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + is Event.OnProfileUpdated -> { state -> + state.copy( + displayName = event.displayName, + phoneNumber = event.phone, + emailAddress = event.email, + phoneLinkedForPayment = event.linkedForPayment, + socialAccounts = event.socialAccounts, + ) + } + + Event.UnlinkPhoneClicked, + Event.UnlinkEmailClicked, + Event.ConnectPhoneClicked, + Event.ConnectEmailClicked, + Event.ReplacePhoneClicked, + Event.ReplaceEmailClicked, + is Event.UnlinkSocialAccountClicked, + Event.NavigateToPhoneVerification, + Event.NavigateToEmailVerification -> { 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 new file mode 100644 index 000000000..0b3f14ec2 --- /dev/null +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt @@ -0,0 +1,103 @@ +package com.flipcash.app.myaccount.internal + +import com.flipcash.services.models.SocialAccount +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ContactMethodsViewModelStateTest { + + private val reduce = UserProfileViewModel.Companion.updateStateForEvent + + @Test + fun `default state has null profile fields`() { + val state = UserProfileViewModel.State() + assertNull(state.displayName) + assertNull(state.phoneNumber) + assertNull(state.emailAddress) + assertFalse(state.phoneLinkedForPayment) + assertTrue(state.socialAccounts.isEmpty()) + } + + @Test + fun `OnProfileUpdated sets all profile fields`() { + val xAccount = SocialAccount.TwitterX( + id = "123", + username = "testuser", + name = "Test User", + description = "desc", + profilePicUrl = "https://example.com/pic.jpg", + verifiedType = SocialAccount.TwitterX.VerifiedType.BLUE, + followerCount = 100, + ) + val updated = reduce( + UserProfileViewModel.Event.OnProfileUpdated( + displayName = "Alice", + phone = "+15551234567", + email = "test@example.com", + linkedForPayment = true, + socialAccounts = listOf(xAccount), + ) + )(UserProfileViewModel.State()) + assertEquals("Alice", updated.displayName) + assertEquals("+15551234567", updated.phoneNumber) + assertEquals("test@example.com", updated.emailAddress) + assertTrue(updated.phoneLinkedForPayment) + assertEquals(1, updated.socialAccounts.size) + assertEquals(xAccount, updated.socialAccounts.first()) + } + + @Test + fun `OnProfileUpdated with null values`() { + val updated = reduce( + UserProfileViewModel.Event.OnProfileUpdated( + displayName = null, + phone = null, + email = null, + linkedForPayment = false, + socialAccounts = emptyList(), + ) + )(UserProfileViewModel.State()) + assertNull(updated.displayName) + assertNull(updated.phoneNumber) + assertNull(updated.emailAddress) + assertFalse(updated.phoneLinkedForPayment) + assertTrue(updated.socialAccounts.isEmpty()) + } + + @Test + fun `no-op events return state unchanged`() { + val xAccount = SocialAccount.TwitterX( + id = "123", + username = "testuser", + name = "Test User", + description = "desc", + profilePicUrl = "https://example.com/pic.jpg", + verifiedType = null, + followerCount = 0, + ) + val state = UserProfileViewModel.State( + displayName = "Alice", + phoneNumber = "+15551234567", + emailAddress = "test@example.com", + phoneLinkedForPayment = true, + socialAccounts = listOf(xAccount), + ) + val noOpEvents = listOf( + UserProfileViewModel.Event.UnlinkPhoneClicked, + UserProfileViewModel.Event.UnlinkEmailClicked, + UserProfileViewModel.Event.ConnectPhoneClicked, + UserProfileViewModel.Event.ConnectEmailClicked, + UserProfileViewModel.Event.ReplacePhoneClicked, + UserProfileViewModel.Event.ReplaceEmailClicked, + UserProfileViewModel.Event.UnlinkSocialAccountClicked(xAccount), + UserProfileViewModel.Event.NavigateToPhoneVerification, + UserProfileViewModel.Event.NavigateToEmailVerification, + ) + noOpEvents.forEach { event -> + assertEquals(state, reduce(event)(state), "Event $event should be no-op") + } + } +} 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 24fa4947f..17d83f400 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 @@ -49,24 +49,22 @@ class MyAccountScreenViewModelStateTest { } @Test - fun `OnBetaFeaturesUnlocked true enables beta and shows all menu items`() { + fun `OnBetaFeaturesUnlocked true enables beta and shows ContactMethods item`() { val updated = reduce( MyAccountScreenViewModel.Event.OnBetaFeaturesUnlocked(true) )(MyAccountScreenViewModel.State()) assertTrue(updated.isBetaEnabled) - assertTrue(updated.items.any { it is VerifyPhone }) - assertTrue(updated.items.any { it is VerifyEmail }) + assertTrue(updated.items.any { it is UserProfile }) } @Test - fun `OnBetaFeaturesUnlocked false disables beta and hides verification items`() { + fun `OnBetaFeaturesUnlocked false disables beta and hides ContactMethods item`() { val state = MyAccountScreenViewModel.State(isBetaEnabled = true) val updated = reduce( MyAccountScreenViewModel.Event.OnBetaFeaturesUnlocked(false) )(state) assertFalse(updated.isBetaEnabled) - assertFalse(updated.items.any { it is VerifyPhone }) - assertFalse(updated.items.any { it is VerifyEmail }) + assertFalse(updated.items.any { it is UserProfile }) } @Test @@ -105,8 +103,8 @@ class MyAccountScreenViewModelStateTest { val noOpEvents = listOf( MyAccountScreenViewModel.Event.OnLogOutClicked, MyAccountScreenViewModel.Event.OnLoggedOutCompletely, - MyAccountScreenViewModel.Event.OnVerifyPhoneClicked, - MyAccountScreenViewModel.Event.OnVerifyEmailClicked, + MyAccountScreenViewModel.Event.OnContactMethodsClicked, + MyAccountScreenViewModel.Event.OnViewUserProfile, MyAccountScreenViewModel.Event.OnViewAccessKey, MyAccountScreenViewModel.Event.CopyPublicKey, MyAccountScreenViewModel.Event.CopyAccountId, diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/ListItem.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/ListItem.kt index d98339174..b027123c7 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/ListItem.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/ListItem.kt @@ -66,11 +66,15 @@ fun ListItem( BetaIndicator() } + if (showBetaIndicator && showChevron) { + Spacer(Modifier.width(CodeTheme.dimens.grid.x2)) + } + if (showChevron) { Icon( painter = painterResource(id = R.drawable.ic_chevron_right), contentDescription = null, - tint = CodeTheme.colors.secondary, + tint = CodeTheme.colors.textSecondary, ) } }