From ddbac76649375b65d21477d825ee74a04408ae82 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 4 Jun 2026 20:03:47 -0400 Subject: [PATCH] refactor(myaccount): merge account info into UserProfileScreen Move account info (Public Key, Account ID, Push Token) from MyAccountScreen hidden title-tap gesture into UserProfileScreen, which is already a staff-only destination. This consolidates developer-facing info and removes the hidden gesture. - Add account info state, copy events, and clipboard handling to UserProfileViewModel - Add Account Info section to UserProfileScreenContent with swipe-to-copy and per-index corner rounding - Remove account info state, events, and title-tap gesture from MyAccountScreen/ViewModel - Delete AccountInfoHeader.kt (dead code) - Add resetOnDismiss to SwipeActionRow for non-destructive swipe actions that animate back after triggering - Apply @Stable, remember optimization, and KDoc to SwipeAction - Update tests for both ViewModels Signed-off-by: Brandon McAnsh --- .../core/src/main/res/values/strings.xml | 1 + .../app/lab/internal/LabsScreenContent.kt | 22 +- .../flipcash/app/myaccount/MyAccountScreen.kt | 2 - .../myaccount/internal/AccountInfoHeader.kt | 115 ------ .../internal/MyAccountScreenContent.kt | 52 +-- .../internal/MyAccountScreenViewModel.kt | 89 ----- .../internal/UserProfileScreenContent.kt | 333 ++++++++++++++---- .../internal/UserProfileViewModel.kt | 74 +++- .../ContactMethodsViewModelStateTest.kt | 34 ++ .../MyAccountScreenViewModelStateTest.kt | 54 +-- .../getcode/ui/components/SwipeActionRow.kt | 76 ++-- .../ui/components/text/SectionHeader.kt | 4 +- 12 files changed, 449 insertions(+), 407 deletions(-) delete mode 100644 apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/AccountInfoHeader.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index fed44faac..0130d2ed0 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -339,6 +339,7 @@ Your email address will be removed from your profile. Display Name Social Accounts + Account Info No display name set No social accounts linked Unlink Account? 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 8e6955d0b..96e39234b 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 @@ -52,11 +52,15 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel) { .verticalScrollStateGradient( scrollState = state, isLongGradient = true, - ).sheetResignmentBehavior(state), + ) + .sheetResignmentBehavior(state), contentPadding = PaddingValues(bottom = CodeTheme.dimens.grid.x3), ) { item { - SectionHeader(stringResource(R.string.title_settingsSectionFeatures)) + SectionHeader( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + title = stringResource(R.string.title_settingsSectionFeatures) + ) } items(betaFlags, key = { it.flag.key }) { feature -> if (feature.flag.isOptionFlag) { @@ -113,7 +117,12 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel) { } } - item { SectionHeader(stringResource(R.string.title_settingsSectionHomeScreen)) } + item { + SectionHeader( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + title = stringResource(R.string.title_settingsSectionHomeScreen) + ) + } item { ListItem( headline = stringResource(R.string.title_settingsButtonOrder), @@ -124,7 +133,12 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel) { } if (isStaff) { - item { SectionHeader(stringResource(R.string.title_settingsSectionDeveloper)) } + item { + SectionHeader( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + title = stringResource(R.string.title_settingsSectionDeveloper) + ) + } item { ListItem( headline = stringResource(R.string.subtitle_settingsUserFlags), 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 ccef20424..3e73486cd 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 @@ -17,7 +17,6 @@ import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle -import com.getcode.ui.core.noRippleClickable import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -35,7 +34,6 @@ fun MyAccountScreen() { AppBarWithTitle( title = { AppBarDefaults.Title( - modifier = Modifier.noRippleClickable { viewModel.dispatchEvent(MyAccountScreenViewModel.Event.OnTitleClicked) }, text = stringResource(R.string.title_myAccount), ) }, diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/AccountInfoHeader.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/AccountInfoHeader.kt deleted file mode 100644 index 03261895a..000000000 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/AccountInfoHeader.kt +++ /dev/null @@ -1,115 +0,0 @@ -package com.flipcash.app.myaccount.internal - -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.Icon -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CopyAll -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight.Companion.W600 -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.getcode.ui.components.BetaIndicator -import com.getcode.theme.CodeTheme -import androidx.compose.foundation.clickable - -@Composable -internal fun AccountInfoHeader( - state: MyAccountScreenViewModel.State, - modifier: Modifier = Modifier, - dispatch: (MyAccountScreenViewModel.Event) -> Unit -) { - Box( - modifier = modifier - .border( - color = CodeTheme.colors.border, - shape = CodeTheme.shapes.small, - width = CodeTheme.dimens.border - ) - .padding(CodeTheme.dimens.grid.x2) - .width(IntrinsicSize.Max) - .height(IntrinsicSize.Min), - ) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1) - ) { - if (!state.publicKey.isNullOrEmpty()) { - CopyableTextEntry( - label = "Public Key", - value = state.publicKey - ) { dispatch(MyAccountScreenViewModel.Event.CopyPublicKey) } - } - - if (!state.accountId.isNullOrEmpty()) { - CopyableTextEntry( - label = "Account ID", - value = state.accountId - ) { dispatch(MyAccountScreenViewModel.Event.CopyAccountId) } - } - - if (!state.pushToken.isNullOrEmpty()) { - CopyableTextEntry( - label = "Push Token", - value = state.pushToken - ) { dispatch(MyAccountScreenViewModel.Event.CopyPushToken) } - } - - BetaIndicator( - modifier = Modifier.align(Alignment.End) - ) - } - } -} - -@Composable -private fun CopyableTextEntry( - label: String, - value: String, - modifier: Modifier = Modifier, - onCopy: () -> Unit -) { - Column( - modifier = modifier.clickable(onClick = onCopy) - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = label, - style = CodeTheme.typography.caption.copy(fontWeight = W600), - color = CodeTheme.colors.textMain - ) - - Icon( - Icons.Default.CopyAll, - contentDescription = "Copy", - tint = CodeTheme.colors.cashBillDecorColor, - modifier = Modifier - .size(20.dp) - .padding(start = CodeTheme.dimens.grid.x1) - ) - } - Text( - modifier = Modifier.fillMaxWidth(), - text = value, - style = CodeTheme.typography.caption, - color = CodeTheme.colors.textSecondary, - overflow = TextOverflow.MiddleEllipsis, - maxLines = 1 - ) - } -} \ No newline at end of file diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenContent.kt index 5a1ff4ffd..45e75931f 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenContent.kt @@ -1,23 +1,11 @@ package com.flipcash.app.myaccount.internal -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.menu.MenuList -import com.getcode.theme.CodeTheme @Composable internal fun MyAccountScreen(viewModel: MyAccountScreenViewModel) { @@ -31,38 +19,10 @@ private fun MyAccountScreenContent( state: MyAccountScreenViewModel.State, dispatch: (MyAccountScreenViewModel.Event) -> Unit ) { - Column( + MenuList( modifier = Modifier.fillMaxSize(), - ) { - AnimatedContent( - modifier = Modifier.fillMaxWidth(), - targetState = state.showAccountInfo, - transitionSpec = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Down) togetherWith - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Up) - } - ) { show -> - if (show) { - AccountInfoHeader( - state = state, - dispatch = dispatch, - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = CodeTheme.dimens.inset, - vertical = CodeTheme.dimens.grid.x2 - ) - ) - } else { - Spacer(Modifier.fillMaxWidth()) - } - } - - MenuList( - modifier = Modifier.weight(1f), - items = state.items, - showChevrons = true, - onItemClick = { dispatch(it.action) } - ) - } -} \ No newline at end of file + items = state.items, + showChevrons = true, + onItemClick = { dispatch(it.action) } + ) +} 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 c9acf1bfc..ddf0d9457 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 @@ -1,9 +1,7 @@ package com.flipcash.app.myaccount.internal -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.FeatureFlagController import com.flipcash.app.menu.MenuItem import com.flipcash.app.menu.StaffMenuItem @@ -12,18 +10,14 @@ 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.solana.keys.base58 import com.getcode.util.resources.ResourceHelper -import com.getcode.utils.base64 import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filter 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 @@ -41,7 +35,6 @@ internal class MyAccountScreenViewModel @Inject constructor( featureFlagController: FeatureFlagController, resources: ResourceHelper, authManager: AuthManager, - clipboardManager: ClipboardManager, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -50,51 +43,22 @@ internal class MyAccountScreenViewModel @Inject constructor( ) { internal data class State( val isBetaEnabled: Boolean = false, - val showAccountInfo: Boolean = false, - val accountId: String? = null, - val publicKey: String? = null, - val pushToken: String? = null, val items: List> = FullMenuList ) internal sealed interface Event { - data class OnUserAssociated( - val userId: String?, - val publicKey: String?, - val pushToken: String? = null, - ) : Event - data class OnBetaFeaturesUnlocked(val unlocked: Boolean) : Event - data object OnTitleClicked : Event - data class ToggleAccountInfo(val show: Boolean) : Event data object OnAccessKeyClicked : Event data object OnViewAccessKey : Event data object OnContactMethodsClicked : Event data object OnViewUserProfile : Event data object OnDeleteAccountClicked : Event data object OnAccountDeleted : Event - data object CopyPublicKey : Event - data object CopyAccountId : Event - data object CopyPushToken : Event data object OnLogOutClicked : Event data object OnLoggedOutCompletely : Event } init { - 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, - ) - ) - }.launchIn(viewModelScope) - combine( featureFlagController.observeOverride(), userManager.state.map { it.flags?.isStaff == true } @@ -104,13 +68,6 @@ internal class MyAccountScreenViewModel @Inject constructor( dispatchEvent(Event.OnBetaFeaturesUnlocked(it)) }.launchIn(viewModelScope) - eventFlow - .filterIsInstance() - .filter { stateFlow.value.isBetaEnabled } - .map { stateFlow.value.showAccountInfo } - .onEach { dispatchEvent(Event.ToggleAccountInfo(!it)) } - .launchIn(viewModelScope) - eventFlow .filterIsInstance() .onEach { @@ -136,36 +93,6 @@ internal class MyAccountScreenViewModel @Inject constructor( ) }.launchIn(viewModelScope) - eventFlow - .filterIsInstance() - .mapNotNull { stateFlow.value.publicKey } - .onEach { - clipboardManager.setText( - text = it, - label = resources.getString(R.string.title_clipboardLabelPublicKey) - ) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .mapNotNull { stateFlow.value.accountId } - .onEach { - clipboardManager.setText( - text = it, - label = resources.getString(R.string.title_clipboardLabelAccountId) - ) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .mapNotNull { stateFlow.value.pushToken } - .onEach { - clipboardManager.setText( - text = it, - label = resources.getString(R.string.title_clipboardLabelPushToken) - ) - }.launchIn(viewModelScope) - eventFlow .filterIsInstance() .onEach { @@ -230,23 +157,11 @@ internal class MyAccountScreenViewModel @Inject constructor( val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { - is Event.OnUserAssociated -> { state -> - state.copy( - accountId = event.userId, - publicKey = event.publicKey, - pushToken = event.pushToken, - ) - } - Event.OnLogOutClicked, Event.OnLoggedOutCompletely, Event.OnContactMethodsClicked, Event.OnViewUserProfile, Event.OnViewAccessKey, - Event.CopyPublicKey, - Event.CopyAccountId, - Event.CopyPushToken, - Event.OnTitleClicked, Event.OnDeleteAccountClicked, Event.OnAccountDeleted, Event.OnAccessKeyClicked -> { state -> state } @@ -257,10 +172,6 @@ internal class MyAccountScreenViewModel @Inject constructor( items = buildItemList(event.unlocked) ) } - - is Event.ToggleAccountInfo -> { state -> - state.copy(showAccountInfo = event.show) - } } } } 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 index 535c0488d..f5eaea00c 100644 --- 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 @@ -1,52 +1,152 @@ package com.flipcash.app.myaccount.internal +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.Column +import androidx.compose.foundation.layout.PaddingValues 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.requiredSize 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.material.icons.filled.ContentCopy +import androidx.compose.material3.HorizontalDivider +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.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.res.stringResource -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text +import androidx.compose.ui.text.font.FontWeight.Companion.W600 +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import com.flipcash.core.R import com.flipcash.services.models.SocialAccount 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.foundation.clickable @Composable internal fun UserProfileScreenContent( state: UserProfileViewModel.State, dispatch: (UserProfileViewModel.Event) -> Unit, ) { - LazyColumn(modifier = Modifier.fillMaxSize()) { + val inset = CodeTheme.dimens.inset + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = inset), + ) { + // Account Info section + val hasAccountInfo = !state.publicKey.isNullOrEmpty() || + !state.accountId.isNullOrEmpty() || + !state.pushToken.isNullOrEmpty() + + if (hasAccountInfo) { + item { SectionHeader(stringResource(R.string.title_sectionAccountInfo)) } + + val entries = buildList { + if (!state.publicKey.isNullOrEmpty()) { + add( + Triple( + "Public Key", + state.publicKey, + UserProfileViewModel.Event.CopyPublicKey + ) + ) + } + if (!state.accountId.isNullOrEmpty()) { + add( + Triple( + "Account ID", + state.accountId, + UserProfileViewModel.Event.CopyAccountId + ) + ) + } + if (!state.pushToken.isNullOrEmpty()) { + add( + Triple( + "Push Token", + state.pushToken, + UserProfileViewModel.Event.CopyPushToken + ) + ) + } + } + + item { + Column( + modifier = Modifier + .background(Color.Transparent, CodeTheme.shapes.medium) + .clip(CodeTheme.shapes.medium) + .fillMaxWidth() + ) { + val cornerRadius = 12.dp // medium + entries.forEachIndexed { index, (label, value, event) -> + val shape = when { + entries.size == 1 -> CodeTheme.shapes.medium + index == 0 -> RoundedCornerShape( + topStart = cornerRadius, + topEnd = cornerRadius, + ) + index == entries.lastIndex -> RoundedCornerShape( + bottomStart = cornerRadius, + bottomEnd = cornerRadius, + ) + else -> RectangleShape + } + CopySwipeRow( + onCopy = { dispatch(event) }, + stateKey = value, + ) { + CopyableTextEntry( + modifier = Modifier + .cardStyle(shape = shape), + label = label, + value = value + ) + } + if (index < entries.lastIndex) { + HorizontalDivider( + color = CodeTheme.colors.divider, + thickness = 0.5.dp, + ) + } + } + } + } + } + // Display Name section item { SectionHeader(stringResource(R.string.title_sectionDisplayName)) } item { val name = state.displayName if (!name.isNullOrEmpty()) { - ProfileValueRow(value = name) + CardRow { 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, - ), - ) + CardRow { + 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, + ), + ) + } } } @@ -54,19 +154,26 @@ internal fun UserProfileScreenContent( 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) }, - ) + SwipeActionRow( + onDelete = { dispatch(UserProfileViewModel.Event.UnlinkPhoneClicked) }, + stateKey = state.phoneNumber, + resetOnDismiss = true, + ) { + ContactMethodRow( + value = state.phoneNumber, + subtitle = if (state.phoneLinkedForPayment) { + stringResource(R.string.subtitle_linkedForPayments) + } else null, + onRowClick = { dispatch(UserProfileViewModel.Event.ReplacePhoneClicked) }, + ) + } } else { - AddContactMethodRow( - label = stringResource(R.string.action_addPhoneNumber), - onClick = { dispatch(UserProfileViewModel.Event.ConnectPhoneClicked) }, - ) + CardRow { + AddContactMethodRow( + label = stringResource(R.string.action_addPhoneNumber), + onClick = { dispatch(UserProfileViewModel.Event.ConnectPhoneClicked) }, + ) + } } } @@ -74,17 +181,24 @@ internal fun UserProfileScreenContent( 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) }, - ) + SwipeActionRow( + onDelete = { dispatch(UserProfileViewModel.Event.UnlinkEmailClicked) }, + stateKey = state.emailAddress, + resetOnDismiss = true, + ) { + ContactMethodRow( + value = state.emailAddress, + subtitle = null, + onRowClick = { dispatch(UserProfileViewModel.Event.ReplaceEmailClicked) }, + ) + } } else { - AddContactMethodRow( - label = stringResource(R.string.action_addEmailAddress), - onClick = { dispatch(UserProfileViewModel.Event.ConnectEmailClicked) }, - ) + CardRow { + AddContactMethodRow( + label = stringResource(R.string.action_addEmailAddress), + onClick = { dispatch(UserProfileViewModel.Event.ConnectEmailClicked) }, + ) + } } } @@ -92,31 +206,61 @@ internal fun UserProfileScreenContent( 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, - ), - ) + CardRow { + 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 -> + state.socialAccounts.forEach { account -> when (account) { - is SocialAccount.TwitterX -> SocialAccountRow( - account = account, - onDeleteClick = { - dispatch(UserProfileViewModel.Event.UnlinkSocialAccountClicked(account)) - }, - ) + is SocialAccount.TwitterX -> item(key = account.id) { + SwipeActionRow( + onDelete = { + dispatch( + UserProfileViewModel.Event.UnlinkSocialAccountClicked(account) + ) + }, + stateKey = account.id, + resetOnDismiss = true, + ) { + SocialAccountRow(account = account) + } + } } } } } } +/** Non-swipeable card wrapper — just visual styling. */ +@Composable +private fun CardRow( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Column( + modifier = modifier.cardStyle(), + ) { + content() + } +} + +@Composable +private fun Modifier.cardStyle(shape: Shape = CodeTheme.shapes.medium): Modifier { + return this + .background(CodeTheme.colors.bannerThemed, shape) + .clip(shape) + .fillMaxWidth() +} + @Composable private fun ProfileValueRow(value: String) { Text( @@ -135,12 +279,11 @@ private fun ContactMethodRow( value: String, subtitle: String?, onRowClick: () -> Unit, - onDeleteClick: () -> Unit, ) { Row( modifier = Modifier + .cardStyle() .clickable { onRowClick() } - .fillMaxWidth() .padding( horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x3, @@ -164,25 +307,16 @@ private fun ContactMethodRow( ) } } - 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() + .cardStyle() .padding( horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x3, @@ -204,14 +338,6 @@ private fun SocialAccountRow( 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), - ) - } } } @@ -245,3 +371,60 @@ private fun AddContactMethodRow( ) } } + +@Composable +private fun CopySwipeRow( + onCopy: () -> Unit, + modifier: Modifier = Modifier, + stateKey: Any? = null, + content: @Composable () -> Unit, +) { + SwipeActionRow( + actions = listOf( + SwipeAction( + background = CodeTheme.colors.brandLight, + onTriggered = onCopy, + resetOnDismiss = true, + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + tint = Color.White, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), + ) + } + ), + modifier = modifier, + stateKey = stateKey, + content = content, + ) +} + +@Composable +private fun CopyableTextEntry( + label: String, + value: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .padding( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.grid.x3, + ), + ) { + Text( + text = label, + style = CodeTheme.typography.caption.copy(fontWeight = W600), + color = CodeTheme.colors.textMain, + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = value, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + overflow = TextOverflow.MiddleEllipsis, + maxLines = 1, + ) + } +} 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 index 6316386c3..4f8144d02 100644 --- 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 @@ -1,7 +1,9 @@ package com.flipcash.app.myaccount.internal +import android.content.ClipboardManager import androidx.lifecycle.viewModelScope import com.flipcash.app.contacts.ContactCoordinator +import com.flipcash.app.core.extensions.setText import com.flipcash.core.R import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.controllers.ContactVerificationController @@ -11,13 +13,17 @@ 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.opencode.model.core.uuid +import com.getcode.solana.keys.base58 import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.base64 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.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject @@ -29,6 +35,7 @@ internal class UserProfileViewModel @Inject constructor( private val contactCoordinator: ContactCoordinator, private val profileController: ProfileController, private val resources: ResourceHelper, + clipboardManager: ClipboardManager, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -41,6 +48,9 @@ internal class UserProfileViewModel @Inject constructor( val emailAddress: String? = null, val phoneLinkedForPayment: Boolean = false, val socialAccounts: List = emptyList(), + val publicKey: String? = null, + val accountId: String? = null, + val pushToken: String? = null, ) internal sealed interface Event { @@ -61,6 +71,16 @@ internal class UserProfileViewModel @Inject constructor( data class UnlinkSocialAccountClicked(val account: SocialAccount.TwitterX) : Event data object NavigateToPhoneVerification : Event data object NavigateToEmailVerification : Event + + data class OnAccountInfoUpdated( + val publicKey: String?, + val accountId: String?, + val pushToken: String?, + ) : Event + + data object CopyPublicKey : Event + data object CopyAccountId : Event + data object CopyPushToken : Event } init { @@ -154,6 +174,47 @@ internal class UserProfileViewModel @Inject constructor( .filterIsInstance() .onEach { dispatchEvent(Event.NavigateToEmailVerification) } .launchIn(viewModelScope) + + userManager.state + .onEach { state -> + dispatchEvent( + Event.OnAccountInfoUpdated( + publicKey = state.cluster?.authorityPublicKey?.base58(), + accountId = state.accountId?.uuid?.toString()?.uppercase(), + pushToken = state.pushToken, + ) + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { stateFlow.value.publicKey } + .onEach { + clipboardManager.setText( + text = it, + label = resources.getString(R.string.title_clipboardLabelPublicKey) + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { stateFlow.value.accountId } + .onEach { + clipboardManager.setText( + text = it, + label = resources.getString(R.string.title_clipboardLabelAccountId) + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { stateFlow.value.pushToken } + .onEach { + clipboardManager.setText( + text = it, + label = resources.getString(R.string.title_clipboardLabelPushToken) + ) + }.launchIn(viewModelScope) } private suspend fun unlinkPhone() { @@ -206,6 +267,14 @@ internal class UserProfileViewModel @Inject constructor( ) } + is Event.OnAccountInfoUpdated -> { state -> + state.copy( + publicKey = event.publicKey, + accountId = event.accountId, + pushToken = event.pushToken, + ) + } + Event.UnlinkPhoneClicked, Event.UnlinkEmailClicked, Event.ConnectPhoneClicked, @@ -214,7 +283,10 @@ internal class UserProfileViewModel @Inject constructor( Event.ReplaceEmailClicked, is Event.UnlinkSocialAccountClicked, Event.NavigateToPhoneVerification, - Event.NavigateToEmailVerification -> { state -> state } + Event.NavigateToEmailVerification, + Event.CopyPublicKey, + Event.CopyAccountId, + Event.CopyPushToken -> { 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 0b3f14ec2..8c22ecb75 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 @@ -19,6 +19,9 @@ class ContactMethodsViewModelStateTest { assertNull(state.emailAddress) assertFalse(state.phoneLinkedForPayment) assertTrue(state.socialAccounts.isEmpty()) + assertNull(state.publicKey) + assertNull(state.accountId) + assertNull(state.pushToken) } @Test @@ -67,6 +70,34 @@ class ContactMethodsViewModelStateTest { assertTrue(updated.socialAccounts.isEmpty()) } + @Test + fun `OnAccountInfoUpdated sets account fields`() { + val updated = reduce( + UserProfileViewModel.Event.OnAccountInfoUpdated( + publicKey = "pk-abc", + accountId = "user-123", + pushToken = "token-xyz", + ) + )(UserProfileViewModel.State()) + assertEquals("pk-abc", updated.publicKey) + assertEquals("user-123", updated.accountId) + assertEquals("token-xyz", updated.pushToken) + } + + @Test + fun `OnAccountInfoUpdated with null values`() { + val updated = reduce( + UserProfileViewModel.Event.OnAccountInfoUpdated( + publicKey = null, + accountId = null, + pushToken = null, + ) + )(UserProfileViewModel.State()) + assertNull(updated.publicKey) + assertNull(updated.accountId) + assertNull(updated.pushToken) + } + @Test fun `no-op events return state unchanged`() { val xAccount = SocialAccount.TwitterX( @@ -95,6 +126,9 @@ class ContactMethodsViewModelStateTest { UserProfileViewModel.Event.UnlinkSocialAccountClicked(xAccount), UserProfileViewModel.Event.NavigateToPhoneVerification, UserProfileViewModel.Event.NavigateToEmailVerification, + UserProfileViewModel.Event.CopyPublicKey, + UserProfileViewModel.Event.CopyAccountId, + UserProfileViewModel.Event.CopyPushToken, ) 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 17d83f400..3c54560e6 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 @@ -3,7 +3,6 @@ package com.flipcash.app.myaccount.internal import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNull import kotlin.test.assertTrue class MyAccountScreenViewModelStateTest { @@ -11,41 +10,9 @@ class MyAccountScreenViewModelStateTest { private val reduce = MyAccountScreenViewModel.Companion.updateStateForEvent @Test - fun `default state has null user info and beta disabled`() { + fun `default state has beta disabled`() { val state = MyAccountScreenViewModel.State() - assertNull(state.accountId) - assertNull(state.publicKey) - assertNull(state.pushToken) assertFalse(state.isBetaEnabled) - assertFalse(state.showAccountInfo) - } - - @Test - fun `OnUserAssociated sets user fields`() { - val updated = reduce( - MyAccountScreenViewModel.Event.OnUserAssociated( - userId = "user-123", - publicKey = "pk-abc", - pushToken = "token-xyz" - ) - )(MyAccountScreenViewModel.State()) - assertEquals("user-123", updated.accountId) - assertEquals("pk-abc", updated.publicKey) - assertEquals("token-xyz", updated.pushToken) - } - - @Test - fun `OnUserAssociated with null values`() { - val updated = reduce( - MyAccountScreenViewModel.Event.OnUserAssociated( - userId = null, - publicKey = null, - pushToken = null - ) - )(MyAccountScreenViewModel.State()) - assertNull(updated.accountId) - assertNull(updated.publicKey) - assertNull(updated.pushToken) } @Test @@ -84,32 +51,15 @@ class MyAccountScreenViewModelStateTest { assertTrue(withoutBeta.items.any { it is DeleteAccount }) } - @Test - fun `ToggleAccountInfo sets showAccountInfo`() { - val shown = reduce( - MyAccountScreenViewModel.Event.ToggleAccountInfo(true) - )(MyAccountScreenViewModel.State()) - assertTrue(shown.showAccountInfo) - - val hidden = reduce( - MyAccountScreenViewModel.Event.ToggleAccountInfo(false) - )(shown) - assertFalse(hidden.showAccountInfo) - } - @Test fun `no-op events return state unchanged`() { - val state = MyAccountScreenViewModel.State(accountId = "test", isBetaEnabled = true) + val state = MyAccountScreenViewModel.State(isBetaEnabled = true) val noOpEvents = listOf( MyAccountScreenViewModel.Event.OnLogOutClicked, MyAccountScreenViewModel.Event.OnLoggedOutCompletely, MyAccountScreenViewModel.Event.OnContactMethodsClicked, MyAccountScreenViewModel.Event.OnViewUserProfile, MyAccountScreenViewModel.Event.OnViewAccessKey, - MyAccountScreenViewModel.Event.CopyPublicKey, - MyAccountScreenViewModel.Event.CopyAccountId, - MyAccountScreenViewModel.Event.CopyPushToken, - MyAccountScreenViewModel.Event.OnTitleClicked, MyAccountScreenViewModel.Event.OnDeleteAccountClicked, MyAccountScreenViewModel.Event.OnAccountDeleted, MyAccountScreenViewModel.Event.OnAccessKeyClicked, diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeActionRow.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeActionRow.kt index 9321dc7c4..da94f4022 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeActionRow.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeActionRow.kt @@ -5,7 +5,7 @@ import androidx.compose.foundation.gestures.AnchoredDraggableState import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.anchoredDraggable -import androidx.compose.foundation.gestures.snapTo +import androidx.compose.foundation.gestures.animateTo import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -25,8 +25,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState @@ -34,7 +36,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.onSizeChanged @@ -57,11 +58,16 @@ import kotlinx.coroutines.launch * @param background Background color of the action button. * @param onTriggered Callback when the action is tapped, or when a full swipe * triggers the rightmost action automatically. + * @param resetOnDismiss When true, a full swipe triggers [onTriggered] and animates + * the row back instead of settling at the dismissed position. Callbacks used with + * this flag should be idempotent (e.g. clipboard writes). * @param content Composable content displayed inside the action button (typically an [Icon]). */ +@Stable class SwipeAction( val background: Color, val onTriggered: () -> Unit, + val resetOnDismiss: Boolean = false, val content: @Composable () -> Unit, ) @@ -75,22 +81,29 @@ fun SwipeActionRow( onDelete: () -> Unit, modifier: Modifier = Modifier, stateKey: Any? = null, + resetOnDismiss: Boolean = false, content: @Composable () -> Unit, ) { - SwipeActionRow( - actions = listOf( + val errorColor = CodeTheme.colors.error + val iconSize = CodeTheme.dimens.staticGrid.x5 + val actions = remember(onDelete, resetOnDismiss, errorColor, iconSize) { + listOf( SwipeAction( - background = CodeTheme.colors.error, + background = errorColor, onTriggered = onDelete, + resetOnDismiss = resetOnDismiss, ) { Icon( painter = painterResource(R.drawable.ic_delete), contentDescription = null, tint = Color.White, - modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), + modifier = Modifier.requiredSize(iconSize), ) } - ), + ) + } + SwipeActionRow( + actions = actions, modifier = modifier, stateKey = stateKey, content = content, @@ -128,10 +141,43 @@ fun SwipeActionRow( val totalRevealWidth = (minActionSize + actionPadding) * actions.size + actionPadding val totalRevealWidthPx = with(density) { totalRevealWidth.toPx() } + val dismissAction = actions.last() + val currentDismissCallback by rememberUpdatedState(dismissAction.onTriggered) + val currentDismissResets by rememberUpdatedState(dismissAction.resetOnDismiss) + + var pendingResetCallback by remember(stateKey) { mutableStateOf(false) } + val initialState = if (initiallyRevealed) SwipeState.Revealed else SwipeState.Settled - val state = remember(stateKey) { AnchoredDraggableState(initialValue = initialState) } + val state = remember(stateKey) { + AnchoredDraggableState( + initialValue = initialState, + confirmValueChange = { newValue -> + if (newValue == SwipeState.Dismissed && currentDismissResets) { + pendingResetCallback = true + false // reject settle, system animates back + } else { + true + } + }, + ) + } var rowWidthPx by remember(stateKey) { mutableFloatStateOf(0f) } + // Fire callback once for rejected dismiss (reset case) + LaunchedEffect(pendingResetCallback) { + if (pendingResetCallback) { + currentDismissCallback() + pendingResetCallback = false + } + } + + // Fire callback for accepted dismiss (non-reset case, e.g. delete) + LaunchedEffect(state.currentValue) { + if (state.currentValue == SwipeState.Dismissed) { + currentDismissCallback() + } + } + LaunchedEffect(rowWidthPx, totalRevealWidthPx) { if (rowWidthPx > 0f) { state.updateAnchors(DraggableAnchors { @@ -142,22 +188,12 @@ fun SwipeActionRow( } } - val dismissAction = actions.last() - val currentDismissCallback by rememberUpdatedState(dismissAction.onTriggered) - LaunchedEffect(state.currentValue) { - if (state.currentValue == SwipeState.Dismissed) { - currentDismissCallback() - } - } - Box( modifier = modifier - .clipToBounds() .onSizeChanged { rowWidthPx = it.width.toFloat() }, ) { // Actions panel behind the foreground content val actionPaddingDp = actionPadding - val minActionSizeDp = minActionSize Layout( content = { @@ -167,8 +203,8 @@ fun SwipeActionRow( .clip(RoundedCornerShape(50)) .background(action.background) .clickable { - scope.launch { state.snapTo(SwipeState.Settled) } action.onTriggered() + scope.launch { state.animateTo(SwipeState.Settled) } }, contentAlignment = Alignment.Center, ) { @@ -180,7 +216,7 @@ fun SwipeActionRow( ) { measurables, constraints -> val absOffset = abs(state.offset) val padPx = actionPaddingDp.roundToPx() - val minPx = minActionSizeDp.roundToPx() + val minPx = minActionSize.roundToPx() val n = measurables.size val actionH = (constraints.maxHeight - padPx * 2).coerceAtLeast(minPx) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/text/SectionHeader.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/text/SectionHeader.kt index 4eea48fd5..e94a10e87 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/text/SectionHeader.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/text/SectionHeader.kt @@ -10,14 +10,12 @@ import com.getcode.theme.CodeTheme @Composable fun SectionHeader(title: String, modifier: Modifier = Modifier) { Text( - text = title.uppercase(), + text = title, style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, modifier = modifier .fillMaxWidth() .padding( - start = CodeTheme.dimens.inset, - end = CodeTheme.dimens.inset, top = CodeTheme.dimens.grid.x4, bottom = CodeTheme.dimens.grid.x1, )