From 77068bf2d50792d3717e35766adfa3a7c8209ceb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 17:42:54 -0400 Subject: [PATCH 1/8] feat(you): rebuild the You tab around the tip card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the "Tip Card You Page" design (node 9216:656). The You tab (the Menu feature under the v2 tab bar) drops its app bar and money tiles and leads with the viewer's own tip card: - the card renders opaque at the design's flattened fill, sits under a "Full Screen" affordance that presents it over the camera, and the status-bar clearance moves to the list's content padding so the card's own inset isn't paid twice - a copy row shows the abbreviated tip link and copies the full URL - Share and Download tiles sit below it. Download opens a "Download As" sheet (PNG / SVG) and hands the rendered file to the Sharesheet — Android has no permissionless save-to-Photos, and the chooser already offers Files/Drive/Photos - the full-screen presentation of your own card gets a Close affordance instead of the Send-a-Tip modal, via a new SelfTipCardDecorator - TipCardMaxWidth is raised so a phone-width canvas gets the full 0.82 fraction the design specifies Settings rows are restyled (17sp headline, 24dp icon, chevron extracted to ListItemDefaults) and rearranged: - My Account keeps the account-shaped rows — Change Display Name, Require Biometrics, Blocked - Access Key, Log Out and Delete Account move to Advanced, alongside Beta Features and Application Logs - the standalone App Settings screen is removed; its only surviving toggle moved into My Account, and Auto Start Camera is dropped (the camera now always auto-starts) --- apps/flipcash/app/build.gradle.kts | 1 - .../ui/navigation/AppScreenContent.kt | 2 - .../kotlin/com/flipcash/app/core/AppRoute.kt | 2 - .../core/src/main/res/values/strings.xml | 17 +- .../features/advanced/build.gradle.kts | 1 + .../app/advanced/AdvancedFeaturesScreen.kt | 28 +++ .../internal/AdvancedFeatureMenuItems.kt | 36 ++- .../AdvancedFeaturesScreenViewModel.kt | 96 ++++++- apps/flipcash/features/appsettings/.gitignore | 2 - .../features/appsettings/build.gradle.kts | 12 - .../app/appsettings/AppSettingsScreen.kt | 30 --- .../internal/AppSettingsScreenContent.kt | 55 ---- .../app/menu/internal/DownloadOptions.kt | 88 +++++++ .../flipcash/app/menu/internal/MenuItems.kt | 14 +- .../app/menu/internal/MenuScreenContent.kt | 236 +++++++++++++++--- .../app/menu/internal/MenuScreenViewModel.kt | 76 +++++- .../features/myaccount/build.gradle.kts | 2 + .../flipcash/app/myaccount/MyAccountScreen.kt | 26 -- .../internal/myaccount/MyAccountMenuItems.kt | 50 ++-- .../myaccount/MyAccountScreenContent.kt | 37 ++- .../myaccount/MyAccountScreenViewModel.kt | 168 +++---------- .../MyAccountScreenViewModelStateTest.kt | 67 +++-- .../internal/bills/ScannableContainer.kt | 19 +- .../app/appsettings/AppSettingValue.kt | 6 - .../appsettings/internal/AppSettingsMapper.kt | 7 - .../app/bills/components/cards/TipCard.kt | 4 +- .../app/bills/decor/ScannableDecorator.kt | 6 +- .../app/bills/decor/SelfTipCardDecorator.kt | 66 +++++ .../flipcash/app/session/SessionController.kt | 1 - .../session/internal/RealSessionController.kt | 5 - .../app/shareable/ShareSheetController.kt | 16 ++ .../InternalShareConfirmationController.kt | 1 + .../internal/InternalShareSheetController.kt | 33 +++ settings.gradle.kts | 1 - .../com/getcode/ui/components/ListItem.kt | 38 ++- .../src/main/res/drawable/ic_bezier_curve.xml | 72 ++++++ .../src/main/res/drawable/ic_chain_link.xml | 14 ++ .../res/drawable/ic_chevron_down_medium.xml | 9 + .../src/main/res/drawable/ic_copy.xml | 12 + .../src/main/res/drawable/ic_file_bend.xml | 17 ++ .../main/res/drawable/ic_file_download.xml | 13 + .../src/main/res/drawable/ic_maintenance.xml | 9 + .../main/res/drawable/ic_people_id_card.xml | 10 + .../src/main/res/drawable/ic_share_os.xml | 24 ++ 44 files changed, 995 insertions(+), 434 deletions(-) delete mode 100644 apps/flipcash/features/appsettings/.gitignore delete mode 100644 apps/flipcash/features/appsettings/build.gradle.kts delete mode 100644 apps/flipcash/features/appsettings/src/main/kotlin/com/flipcash/app/appsettings/AppSettingsScreen.kt delete mode 100644 apps/flipcash/features/appsettings/src/main/kotlin/com/flipcash/app/appsettings/internal/AppSettingsScreenContent.kt create mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/DownloadOptions.kt create mode 100644 apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/SelfTipCardDecorator.kt create mode 100644 ui/resources/src/main/res/drawable/ic_bezier_curve.xml create mode 100644 ui/resources/src/main/res/drawable/ic_chain_link.xml create mode 100644 ui/resources/src/main/res/drawable/ic_chevron_down_medium.xml create mode 100644 ui/resources/src/main/res/drawable/ic_copy.xml create mode 100644 ui/resources/src/main/res/drawable/ic_file_bend.xml create mode 100644 ui/resources/src/main/res/drawable/ic_file_download.xml create mode 100644 ui/resources/src/main/res/drawable/ic_maintenance.xml create mode 100644 ui/resources/src/main/res/drawable/ic_people_id_card.xml create mode 100644 ui/resources/src/main/res/drawable/ic_share_os.xml diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index 367a00c656..eeb55d19de 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -223,7 +223,6 @@ dependencies { implementation(project(":apps:flipcash:features:lab")) implementation(project(":apps:flipcash:features:advanced")) implementation(project(":apps:flipcash:features:device-logs")) - implementation(project(":apps:flipcash:features:appsettings")) implementation(project(":apps:flipcash:features:appupdates")) implementation(project(":apps:flipcash:features:deposit")) implementation(project(":apps:flipcash:features:myaccount")) 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 2e6b295480..80cbc8f3c9 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 @@ -19,7 +19,6 @@ import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.scene.OverlayScene import androidx.navigation3.scene.SinglePaneSceneStrategy import com.flipcash.app.advanced.AdvancedFeaturesScreen -import com.flipcash.app.appsettings.AppSettingsScreen import com.flipcash.app.devicelogs.DeviceLogsScreen import com.flipcash.app.backupkey.BackupKeyScreen import com.flipcash.app.balance.BalanceScreen @@ -146,7 +145,6 @@ fun appEntryProvider( // Menu - annotatedEntry { AppSettingsScreen() } annotatedEntry { key -> LabsScreen(onboarding = key.onboarding) } annotatedEntry { NavBarSettingsScreen() } annotatedEntry { UserProfileScreen() } 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 629dbe7667..729de1e98a 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 @@ -302,8 +302,6 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data object Blocklist: Menu @Serializable - data object AppSettings : Menu - @Serializable data object AdvancedFeatures : Menu @Serializable data object DeviceLogs : Menu diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index fe0c8af939..39173ed43f 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -972,4 +972,19 @@ Send Your First Tip - \ No newline at end of file + + Full Screen + Close + Download + Tip Card Link + Download As + PNG + Social, chats, overlays + SVG + Stays sharp at any size + My Flipcash Code + Couldn\'t Export + We were unable to export your tip code. Please try again + Change Display Name + + diff --git a/apps/flipcash/features/advanced/build.gradle.kts b/apps/flipcash/features/advanced/build.gradle.kts index a8a69786a8..4c8b7dd01b 100644 --- a/apps/flipcash/features/advanced/build.gradle.kts +++ b/apps/flipcash/features/advanced/build.gradle.kts @@ -8,6 +8,7 @@ android { dependencies { implementation(project(":apps:flipcash:features:device-logs")) + implementation(project(":apps:flipcash:shared:authentication")) implementation(project(":apps:flipcash:shared:bill-customization")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:menu")) diff --git a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/AdvancedFeaturesScreen.kt b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/AdvancedFeaturesScreen.kt index fef062cb91..3c03919518 100644 --- a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/AdvancedFeaturesScreen.kt +++ b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/AdvancedFeaturesScreen.kt @@ -12,6 +12,7 @@ import com.flipcash.app.advanced.internal.AdvancedFeaturesScreen import com.flipcash.app.advanced.internal.AdvancedFeaturesScreenViewModel import com.flipcash.app.bill.customization.Event import com.flipcash.app.bill.customization.LocalBillPlaygroundController +import com.flipcash.app.core.AppRoute import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.opencode.model.financial.Token @@ -56,4 +57,31 @@ fun AdvancedFeaturesScreen() { } .launchIn(this) } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { navigator.push(AppRoute.Menu.BackupKey) } + .launchIn(this) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.hide() + navigator.replaceAll(AppRoute.OnboardingFlow()) + } + .launchIn(this) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.hide() + navigator.replaceAll(AppRoute.OnboardingFlow()) + } + .launchIn(this) + } } diff --git a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt index 7263a75d21..296be3fa5f 100644 --- a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt +++ b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt @@ -6,11 +6,27 @@ import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Palette import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.flipcash.app.core.AppRoute import com.flipcash.app.menu.FullMenuItem import com.flipcash.core.R +import com.getcode.util.resources.icons.Delete + +/** + * Node 9279:121978. Access Key, Log Out and Delete Account moved here from My Account — they're + * recovery/destructive actions, not account details. + */ +internal data object AccessKey : FullMenuItem() { + override val icon: Painter + @Composable get() = painterResource(R.drawable.ic_hardware_security_key) + override val name: String + @Composable get() = stringResource(R.string.title_accessKey) + override val action: AdvancedFeaturesScreenViewModel.Event = + AdvancedFeaturesScreenViewModel.Event.OnAccessKeyClicked +} internal data object BillCustomizer : FullMenuItem() { override val icon: Painter @@ -37,4 +53,22 @@ internal data object BetaFlags : FullMenuItem() { + override val icon: Painter + @Composable get() = painterResource(R.drawable.ic_menu_logout) + override val name: String + @Composable get() = stringResource(R.string.action_logout) + override val action: AdvancedFeaturesScreenViewModel.Event = + AdvancedFeaturesScreenViewModel.Event.OnLogOutClicked +} + +internal data object DeleteAccount : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(ImageVector.Delete) + override val name: String + @Composable get() = stringResource(R.string.action_deleteAccount) + override val action: AdvancedFeaturesScreenViewModel.Event = + AdvancedFeaturesScreenViewModel.Event.OnDeleteAccountClicked +} diff --git a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt index dbb6f1b25b..bccd2f26ba 100644 --- a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt +++ b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt @@ -1,30 +1,43 @@ package com.flipcash.app.advanced.internal import androidx.lifecycle.viewModelScope +import com.flipcash.app.auth.AuthManager import com.flipcash.app.core.AppRoute import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.menu.MenuItem import com.flipcash.app.userflags.UserFlagsCoordinator +import com.flipcash.core.R import com.flipcash.libs.coroutines.DispatcherProvider +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.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import javax.inject.Inject private val FullMenuList = buildList { + add(AccessKey) add(BetaFlags) add(DeviceLogs) // add(BillCustomizer) + add(LogOut) + add(DeleteAccount) } @HiltViewModel internal class AdvancedFeaturesScreenViewModel @Inject constructor( featureFlagController: FeatureFlagController, userFlags: UserFlagsCoordinator, + resources: ResourceHelper, + authManager: AuthManager, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -40,6 +53,12 @@ internal class AdvancedFeaturesScreenViewModel @Inject constructor( data class OnBetaFeaturesUnlocked(val unlocked: Boolean) : Event data class OpenScreen(val screen: AppRoute) : Event data object OpenBillPlayground : Event + data object OnAccessKeyClicked : Event + data object OnViewAccessKey : Event + data object OnLogOutClicked : Event + data object OnLoggedOutCompletely : Event + data object OnDeleteAccountClicked : Event + data object OnAccountDeleted : Event } init { @@ -51,6 +70,73 @@ internal class AdvancedFeaturesScreenViewModel @Inject constructor( }.map { dispatchEvent(Event.OnBetaFeaturesUnlocked(it)) }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_viewAccessKey), + message = resources.getString(R.string.prompt_description_viewAccessKey), + showScrim = true, + showCancel = true, + actions = listOf( + BottomBarAction( + text = resources.getString(R.string.action_viewAccessKey), + onClick = { dispatchEvent(Event.OnViewAccessKey) } + ) + ), + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_logout), + message = resources.getString(R.string.prompt_description_logout), + actions = listOf( + BottomBarAction(resources.getString(R.string.action_logout)) { + viewModelScope.launch { + delay(150) // wait for dismiss + authManager.logout() + .onSuccess { dispatchEvent(Event.OnLoggedOutCompletely) } + .onFailure { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToLogOut), + message = resources.getString(R.string.error_description_failedToLogOut), + ) + } + } + }, + ), + showCancel = true, + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_deleteAccount), + message = resources.getString(R.string.prompt_description_deleteAccount), + actions = listOf( + BottomBarAction(resources.getString(R.string.action_deleteAccount)) { + viewModelScope.launch { + delay(150) // wait for dismiss + authManager.deleteAndLogout() + .onSuccess { dispatchEvent(Event.OnAccountDeleted) } + .onFailure { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToDeleteAccount), + message = resources.getString(R.string.error_description_failedToDeleteAccount), + ) + } + } + } + ), + showCancel = true, + ) + }.launchIn(viewModelScope) } internal companion object { @@ -62,8 +148,14 @@ internal class AdvancedFeaturesScreenViewModel @Inject constructor( ) } - is Event.OpenScreen -> { state -> state } - is Event.OpenBillPlayground -> { state -> state } + is Event.OpenScreen, + Event.OpenBillPlayground, + Event.OnAccessKeyClicked, + Event.OnViewAccessKey, + Event.OnLogOutClicked, + Event.OnLoggedOutCompletely, + Event.OnDeleteAccountClicked, + Event.OnAccountDeleted -> { state -> state } } } } diff --git a/apps/flipcash/features/appsettings/.gitignore b/apps/flipcash/features/appsettings/.gitignore deleted file mode 100644 index 9f2a078806..0000000000 --- a/apps/flipcash/features/appsettings/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -build/ -.gradle/ diff --git a/apps/flipcash/features/appsettings/build.gradle.kts b/apps/flipcash/features/appsettings/build.gradle.kts deleted file mode 100644 index 699125044c..0000000000 --- a/apps/flipcash/features/appsettings/build.gradle.kts +++ /dev/null @@ -1,12 +0,0 @@ -plugins { - alias(libs.plugins.flipcash.android.feature) -} - -android { - namespace = "${Gradle.flipcashNamespace}.features.appsettings" -} - -dependencies { - implementation(project(":apps:flipcash:shared:appsettings")) - implementation(project(":ui:biometrics")) -} diff --git a/apps/flipcash/features/appsettings/src/main/kotlin/com/flipcash/app/appsettings/AppSettingsScreen.kt b/apps/flipcash/features/appsettings/src/main/kotlin/com/flipcash/app/appsettings/AppSettingsScreen.kt deleted file mode 100644 index c21b7ec025..0000000000 --- a/apps/flipcash/features/appsettings/src/main/kotlin/com/flipcash/app/appsettings/AppSettingsScreen.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.flipcash.app.appsettings - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import com.flipcash.app.appsettings.internal.AppSettingsScreenContent -import com.flipcash.core.R -import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.ui.components.AppBarWithTitle - -@Composable -fun AppSettingsScreen() { - val navigator = LocalCodeNavigator.current - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_appSettings), - titleAlignment = Alignment.CenterHorizontally, - onBackIconClicked = navigator::pop - ) - - AppSettingsScreenContent() - } -} diff --git a/apps/flipcash/features/appsettings/src/main/kotlin/com/flipcash/app/appsettings/internal/AppSettingsScreenContent.kt b/apps/flipcash/features/appsettings/src/main/kotlin/com/flipcash/app/appsettings/internal/AppSettingsScreenContent.kt deleted file mode 100644 index 6fef8d314f..0000000000 --- a/apps/flipcash/features/appsettings/src/main/kotlin/com/flipcash/app/appsettings/internal/AppSettingsScreenContent.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.flipcash.app.appsettings.internal - -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.appsettings.AppSettingValue -import com.flipcash.app.appsettings.LocalAppSettings -import com.getcode.libs.biometrics.Biometrics -import com.getcode.ui.components.SettingsSwitchRow -import kotlinx.coroutines.launch - -@Composable -internal fun AppSettingsScreenContent() { - val coordinator = LocalAppSettings.current - val appSettings by coordinator.settings().collectAsStateWithLifecycle(emptyList(), LocalLifecycleOwner.current) - val scope = rememberCoroutineScope() - val context = LocalContext.current - - LazyColumn { - items(appSettings, key = { it.setting.type.key }) { option -> - if (option.visible) { - SettingsSwitchRow( - modifier = Modifier.animateItem(), - enabled = option.available, - title = stringResource(id = option.name), - icon = option.icon, - subtitle = option.description?.let { stringResource(id = it) }, - checked = option.setting.enabled - ) { - val toggle = { - coordinator.update(option.setting.type, !option.setting.enabled) - } - - when (option.setting.type) { - AppSettingValue.BiometricsRequired -> { - scope.launch { - Biometrics.prompt(context, delay = 300) - .onSuccess { toggle() } - } - } - AppSettingValue.CameraStartByDefault -> toggle() - } - - } - } - } - } -} \ No newline at end of file diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/DownloadOptions.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/DownloadOptions.kt new file mode 100644 index 0000000000..4ab029acf2 --- /dev/null +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/DownloadOptions.kt @@ -0,0 +1,88 @@ +package com.flipcash.app.menu.internal + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.share.TipCodeExportFormat +import com.flipcash.features.menu.R +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.theme.CodeTheme +import com.getcode.theme.White +import com.getcode.theme.White50 +import com.getcode.util.resources.ResourceHelper + +/** + * The "Download As" sheet (node 9278:7126): one card per export format, plus Cancel. + * + * Both formats are offered because they're for different things — PNG pastes anywhere, SVG stays + * sharp when scaled. If SVG export ever stops being possible, the design's fallback is to skip this + * sheet and share the PNG straight off the Download tile. + */ +internal fun downloadOptions( + resources: ResourceHelper, + onSelect: (TipCodeExportFormat) -> Unit, +): List = listOf( + formatAction( + title = resources.getString(R.string.label_exportPng), + subtitle = resources.getString(R.string.subtitle_exportPng), + iconRes = R.drawable.ic_file_bend, + testTag = "export_format_png", + onClick = { onSelect(TipCodeExportFormat.Png) }, + ), + formatAction( + title = resources.getString(R.string.label_exportSvg), + subtitle = resources.getString(R.string.subtitle_exportSvg), + iconRes = R.drawable.ic_bezier_curve, + testTag = "export_format_svg", + onClick = { onSelect(TipCodeExportFormat.Svg) }, + ), + BottomBarAction( + text = resources.getString(R.string.action_cancel), + style = BottomBarManager.BottomBarButtonStyle.Text, + ), +) + +private fun formatAction( + title: String, + subtitle: String, + iconRes: Int, + testTag: String, + onClick: () -> Unit, +): BottomBarAction = BottomBarAction( + // `text` is unused for rendering once `content` is supplied, but it's what the action reports + // back through onDismiss, so keep it meaningful. + text = AnnotatedString(title), + inlineContentMap = emptyMap(), + testTag = testTag, + onClick = onClick, + content = { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + style = CodeTheme.typography.textMedium, + color = White, + ) + Text( + text = subtitle, + style = CodeTheme.typography.caption, + color = White50, + ) + } + Icon( + modifier = Modifier.size(32.dp), + painter = painterResource(iconRes), + contentDescription = null, + tint = White, + ) + }, +) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt index 51ff9c8911..b24ad75e2d 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt @@ -13,7 +13,7 @@ import com.flipcash.features.menu.R internal data object MyAccount : FullMenuItem() { override val icon: Painter - @Composable get() = painterResource(R.drawable.ic_menu_account) + @Composable get() = painterResource(R.drawable.ic_people_id_card) override val name: String @Composable get() = stringResource(R.string.title_myAccount) override val action: MenuScreenViewModel.Event = MenuScreenViewModel.Event.OpenScreen( @@ -23,7 +23,7 @@ internal data object MyAccount : FullMenuItem() { internal data object AdvancedFeatures : FullMenuItem() { override val icon: Painter - @Composable get() = painterResource(R.drawable.ic_advanced_features) + @Composable get() = painterResource(R.drawable.ic_maintenance) override val name: String @Composable get() = stringResource(R.string.title_advancedFeatures) override val action: MenuScreenViewModel.Event = MenuScreenViewModel.Event.OpenScreen( @@ -31,16 +31,6 @@ internal data object AdvancedFeatures : FullMenuItem( ) } -internal data object AppSettings : FullMenuItem() { - override val icon: Painter - @Composable get() = painterResource(R.drawable.ic_settings_outline) - override val name: String - @Composable get() = stringResource(R.string.title_appSettings) - override val action: MenuScreenViewModel.Event = MenuScreenViewModel.Event.OpenScreen( - AppRoute.Menu.AppSettings - ) -} - internal data object SwitchAccount : StaffMenuItem() { override val icon: Painter @Composable get() = painterResource(R.drawable.ic_menu_switchaccounts) 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 a3e73a51a3..d7e7e1d182 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 @@ -7,19 +7,28 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsIgnoringVisibility +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -28,7 +37,9 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.bills.ScannableRenderer import com.flipcash.app.bills.components.cards.LocalTipCardBaseAlpha @@ -47,6 +58,9 @@ import com.flipcash.features.menu.R import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme +import com.getcode.theme.White +import com.getcode.theme.White05 +import com.getcode.theme.White50 import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.core.noRippleClickable @@ -76,13 +90,16 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { CodeScaffold( topBar = { - AppBarWithTitle( - modifier = Modifier.fillMaxWidth(), - title = stringResource(if (isNewUi) R.string.title_you else R.string.title_settings), - titleAlignment = Alignment.CenterHorizontally, - // The You tab is entered by tab selection, so it has no Close; the v1 sheet keeps it. - endContent = { if (!isNewUi) AppBarDefaults.Close { navigator.hide() } }, - ) + // v2 has no app bar — the card is the first thing on the page (node 9276:4634). v1 + // keeps the Settings sheet's title + Close. + if (!isNewUi) { + AppBarWithTitle( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.title_settings), + titleAlignment = Alignment.CenterHorizontally, + endContent = { AppBarDefaults.Close { navigator.hide() } }, + ) + } }, bottomBar = { // v1 pins the version footer above the nav bar; v2 scrolls it with the content (footer slot). @@ -102,11 +119,15 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { .fillMaxSize() .padding(padding), items = state.items, + showChevrons = isNewUi, header = { if (isNewUi) { YouHeader( card = state.tipCard, + link = state.tipLink, + onCopyLink = { viewModel.dispatchEvent(Event.CopyTipLink) }, onShare = { viewModel.dispatchEvent(Event.ShareTipCard) }, + onDownload = { viewModel.dispatchEvent(Event.DownloadTipCard) }, ) } else { MoneyTiles(viewModel, navigator) @@ -132,7 +153,13 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { // under it (the version footer was landing behind it). Per-entry via LocalTabBarPadding, // which is only non-zero for tab homes. v1 has no such bar. contentPadding = PaddingValues( - top = CodeTheme.dimens.grid.x3, + // No app bar in v2, so the page owns its own status-bar clearance; the design puts + // the card 74dp below it (node 9278:7301). + top = if (isNewUi) { + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + CardTopSpacing + } else { + CodeTheme.dimens.grid.x3 + }, bottom = LocalTabBarPadding.current.calculateBottomPadding(), ), onItemClick = { @@ -142,13 +169,27 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { } } +/** Distance from the status bar to the top of the tip card (node 9278:7301). */ +private val CardTopSpacing = 74.dp + +/** The at-rest card width on the You tab (node 9278:7301: 241.636). */ +private val YouCardWidth = 242.dp + /** - * The "You" tab header: the viewer's own tip card, tappable to present full screen via the app-root - * bill overlay, plus a "Share as a Link" button. The in-page card fades out while its expanded copy - * is presented in the overlay (opacity, not removal, so nothing reflows on dismiss). + * The "You" tab header (node 9276:4634): the viewer's own tip card with a "Full Screen" affordance, + * the copyable tip link, and the Share / Download tiles. The in-page card fades out while its + * expanded copy is presented in the root bill overlay (opacity, not removal, so nothing reflows on + * dismiss). */ +@OptIn(ExperimentalLayoutApi::class) @Composable -private fun YouHeader(card: Scannable.TipCard?, onShare: () -> Unit) { +private fun YouHeader( + card: Scannable.TipCard?, + link: String?, + onCopyLink: () -> Unit, + onShare: () -> Unit, + onDownload: () -> Unit, +) { if (card == null) return val session = LocalSessionController.current ?: return val billState by session.billState.collectAsStateWithLifecycle() @@ -160,11 +201,8 @@ private fun YouHeader(card: Scannable.TipCard?, onShare: () -> Unit) { ) Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = CodeTheme.dimens.grid.x6), + modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x5), ) { // Static display (no camera behind the card): render it opaque at the design's flattened // colour rather than the translucent frosted fill. Figma flattens the card to rgb(16,16,17). @@ -174,30 +212,168 @@ private fun YouHeader(card: Scannable.TipCard?, onShare: () -> Unit) { ) { Box( modifier = Modifier + // The card pads itself off the status bar for the full-screen overlay; here the + // list's content padding already owns that clearance, so consume the inset + // rather than paying it twice. + .consumeWindowInsets(WindowInsets.statusBarsIgnoringVisibility) .graphicsLayer { alpha = cardAlpha } .noRippleClickable { session.presentOwnTipCard(card) }, contentAlignment = Alignment.Center, ) { - ScannableRenderer(scannable = card, tipCardWidth = 230.dp) + ScannableRenderer(scannable = card, tipCardWidth = YouCardWidth) } } - Text( + Spacer(Modifier.height(CodeTheme.dimens.grid.x6)) + + Row( + modifier = Modifier + .graphicsLayer { alpha = cardAlpha } + .noRippleClickable { session.presentOwnTipCard(card) }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Text( + text = stringResource(R.string.action_viewFullScreen), + style = CodeTheme.typography.textSmall, + color = White50, + ) + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(R.drawable.ic_chevron_down_medium), + contentDescription = null, + tint = White50, + ) + } + + Spacer(Modifier.height(64.dp)) + + Column( modifier = Modifier - .clip(CircleShape) - .background(CodeTheme.colors.surfaceVariant) - .clickable { onShare() } - .padding( - horizontal = CodeTheme.dimens.grid.x4, - vertical = CodeTheme.dimens.grid.x3, - ), - text = stringResource(R.string.action_shareAsLink), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.grid.x5), + verticalArrangement = Arrangement.spacedBy(11.dp), + ) { + if (link != null) { + TipLinkRow(link = link, onCopy = onCopyLink) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(88.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + ShareTile( + modifier = Modifier.weight(1f), + icon = R.drawable.ic_share_os, + label = stringResource(R.string.action_share), + onClick = onShare, + ) + ShareTile( + modifier = Modifier.weight(1f), + icon = R.drawable.ic_file_download, + label = stringResource(R.string.action_download), + onClick = onDownload, + ) + } + } + + Spacer(Modifier.height(19.dp)) + } +} + +/** The tip link, tap-to-copy (node 9276:4748). Shown short — the full URL goes to the clipboard. */ +@Composable +private fun TipLinkRow(link: String, onCopy: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .clip(TileShape) + .background(White05) + .clickable { onCopy() } + .padding(horizontal = CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.weight(1f, fill = false), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.ic_chain_link), + contentDescription = null, + tint = White, + ) + Text( + text = link.abbreviatedLink(), + style = CodeTheme.typography.textSmall.copy(fontSize = 15.sp), + color = White.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.ic_copy), + contentDescription = null, + tint = White, ) } } +/** One of the two square-ish actions under the link (node 9276:4756). */ +@Composable +private fun ShareTile( + modifier: Modifier = Modifier, + icon: Int, + label: String, + onClick: () -> Unit, +) { + Column( + modifier = modifier + .fillMaxSize() + .clip(TileShape) + .background(White05) + .clickable { onClick() } + .padding(vertical = CodeTheme.dimens.grid.x4), + verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier.size(28.dp), + painter = painterResource(icon), + contentDescription = null, + tint = White, + ) + Text( + text = label, + style = CodeTheme.typography.textSmall, + color = White50, + ) + } +} + +private val TileShape = RoundedCornerShape(6.dp) + +/** + * `https://app.flipcash.com/tip/` -> `app.flipcash.com/tip/b0ced...` (node 9276:4753). The + * user never types this — it's a recognisable stand-in for the link the copy button puts on the + * clipboard, so it's cut short rather than ellipsized at whatever width the device happens to give. + */ +private fun String.abbreviatedLink(): String { + val withoutScheme = substringAfter("://") + val lastSegment = withoutScheme.substringAfterLast('/') + if (lastSegment.length <= ABBREVIATED_ID_LENGTH) return withoutScheme + val prefix = withoutScheme.removeSuffix(lastSegment) + return "$prefix${lastSegment.take(ABBREVIATED_ID_LENGTH)}..." +} + +private const val ABBREVIATED_ID_LENGTH = 5 + /** v1 Settings-sheet header: the Add Money / Withdraw tiles (removed from the v2 You tab). */ @Composable private fun MoneyTiles( diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index b38fa986c6..eb23b90651 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -1,5 +1,6 @@ package com.flipcash.app.menu.internal +import android.content.ClipboardManager import androidx.lifecycle.viewModelScope import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.FlipcashAnalyticsService @@ -9,6 +10,10 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.android.VersionInfo import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.extensions.onResult +import com.flipcash.app.core.extensions.setText +import com.flipcash.app.core.share.TipCodeExportFormat +import com.flipcash.app.core.share.TipCodeExporter +import com.flipcash.app.core.util.Linkify import com.flipcash.app.featureflags.BetaFeature import com.flipcash.app.core.toast.SystemToastController import com.flipcash.app.featureflags.FeatureFlagController @@ -26,6 +31,7 @@ import com.flipcash.services.user.UserManager import com.flipcash.shared.tipping.TippingCoordinator import com.getcode.opencode.managers.MnemonicManager import com.flipcash.libs.coroutines.DispatcherProvider +import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel @@ -44,7 +50,6 @@ import javax.inject.Inject private val FullMenuList = buildList { add(MyAccount) - add(AppSettings) add(AdvancedFeatures) add(SwitchAccount) } @@ -65,6 +70,8 @@ internal class MenuScreenViewModel @Inject constructor( private val tippingCoordinator: TippingCoordinator, private val tipCodePreviewCache: TipCodePreviewCache, private val shareable: ShareSheetController, + private val clipboardManager: ClipboardManager, + private val tipCodeExporter: TipCodeExporter, private val resources: ResourceHelper, ) : BaseViewModel( @@ -83,6 +90,8 @@ internal class MenuScreenViewModel @Inject constructor( // The viewer's own tip card, shown at the top of the v2 "You" tab. Null until resolved // (or when the profile has no display name). val tipCard: Scannable.TipCard? = null, + // The shareable URL for [tipCard]. Displayed abbreviated; copied in full. + val tipLink: String? = null, ) sealed interface Event { @@ -97,8 +106,11 @@ internal class MenuScreenViewModel @Inject constructor( data class OpenScreen(val screen: AppRoute) : Event data object OnSwitchAccountsClicked : Event data class OnSwitchAccountTo(val entropy: String): Event - data class OnTipCardPopulated(val card: Scannable.TipCard) : Event + data class OnTipCardPopulated(val card: Scannable.TipCard, val link: String?) : Event data object ShareTipCard : Event + data object CopyTipLink : Event + data object DownloadTipCard : Event + data class ExportTipCard(val format: TipCodeExportFormat) : Event } init { @@ -195,11 +207,62 @@ internal class MenuScreenViewModel @Inject constructor( .distinctUntilChanged() .map { tippingCoordinator.resolveTipCard() } .onResult(onSuccess = { card -> - dispatchEvent(Event.OnTipCardPopulated(card)) - tippingCoordinator.currentUserId?.let { tipCodePreviewCache.prepare(it, card) } + val userId = tippingCoordinator.currentUserId + dispatchEvent(Event.OnTipCardPopulated(card, userId?.let { Linkify.tipcard(it) })) + userId?.let { tipCodePreviewCache.prepare(it, card) } }) .launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .mapNotNull { stateFlow.value.tipLink } + .onEach { link -> + // The row shows an abbreviated link; the clipboard gets the whole thing. + clipboardManager.setText( + text = link, + label = resources.getString(R.string.title_clipboardLabelTipCardLink), + ) + toastController.showToast(R.string.action_copied, replacePrevious = true) + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + BottomBarManager.showMessage( + title = resources.getString(R.string.title_downloadTipCardAs), + actions = downloadOptions(resources) { format -> + dispatchEvent(Event.ExportTipCard(format)) + }, + showCancel = false, + showScrim = true, + ) + } + .launchIn(viewModelScope) + + // Render the chosen format, then hand the file to the Sharesheet — Android has no + // permissionless "save to Photos", and the chooser already offers Files/Drive/Photos. + eventFlow + .filterIsInstance() + .mapNotNull { event -> stateFlow.value.tipCard?.let { it to event.format } } + .onEach { (card, format) -> + val export = tipCodeExporter.export(card, format) + if (export == null) { + BottomBarManager.showMessage( + title = resources.getString(R.string.error_title_tipCardExportFailed), + message = resources.getString(R.string.error_description_tipCardExportFailed), + ) + return@onEach + } + shareable.present( + Shareable.TipCodeImage( + export = export, + title = resources.getString(R.string.title_shareTipCode), + ) + ) + } + .launchIn(viewModelScope) + eventFlow .filterIsInstance() .mapNotNull { tippingCoordinator.currentUserId } @@ -284,13 +347,16 @@ internal class MenuScreenViewModel @Inject constructor( } is Event.OnTipCardPopulated -> { state -> - state.copy(tipCard = event.card) + state.copy(tipCard = event.card, tipLink = event.link) } Event.PresentDepositOptions, Event.CheckForUpdate, Event.OnSwitchAccountsClicked, Event.ShareTipCard, + Event.CopyTipLink, + Event.DownloadTipCard, + is Event.ExportTipCard, is Event.OpenScreen, is Event.OnSwitchAccountTo -> { state -> state } diff --git a/apps/flipcash/features/myaccount/build.gradle.kts b/apps/flipcash/features/myaccount/build.gradle.kts index 456f129f8e..af429be46d 100644 --- a/apps/flipcash/features/myaccount/build.gradle.kts +++ b/apps/flipcash/features/myaccount/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { implementation(libs.compose.paging) + implementation(project(":apps:flipcash:shared:appsettings")) implementation(project(":apps:flipcash:shared:authentication")) implementation(project(":apps:flipcash:shared:blocklist")) implementation(project(":apps:flipcash:shared:common-ui")) @@ -24,4 +25,5 @@ dependencies { implementation(project(":libs:encryption:utils")) implementation(project(":libs:messaging")) implementation(project(":libs:permissions:bindings")) + implementation(project(":ui:biometrics")) } 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 007d6db217..fbd595f076 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 @@ -42,32 +42,6 @@ fun MyAccountScreen() { MyAccountScreen(viewModel) } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.hide() - navigator.replaceAll(AppRoute.OnboardingFlow()) } - .launchIn(this) - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.hide() - navigator.replaceAll(AppRoute.OnboardingFlow()) } - .launchIn(this) - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.push(AppRoute.Menu.BackupKey) } - .launchIn(this) - } - LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt index 4d6c3ad967..caddab419e 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt @@ -5,52 +5,42 @@ import androidx.compose.material.icons.filled.ContactMail import androidx.compose.material.icons.outlined.Block import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.vector.ImageVector 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 -internal data object AccessKey : FullMenuItem() { - override val icon: Painter - @Composable get() = painterResource(R.drawable.ic_hardware_security_key) - override val name: String - @Composable get() = stringResource(R.string.title_accessKey) - override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnAccessKeyClicked -} - -internal data object Blocklist : FullMenuItem() { - override val icon: Painter - @Composable get() = rememberVectorPainter(Icons.Outlined.Block) - override val name: String - @Composable get() = stringResource(R.string.title_blocklist) - override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnBlocklistClicked -} - -internal data object UserProfile : StaffMenuItem() { +/** + * Node 9277:121893. Account-shaped settings only — the destructive/diagnostic rows (Access Key, + * Log Out, Delete Account) moved to Advanced, and the standalone App Settings screen folded its one + * surviving toggle (Require Biometrics) in here. + */ +internal data object DisplayName : FullMenuItem() { override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.ContactMail) override val name: String - @Composable get() = stringResource(CoreR.string.title_userProfile) + @Composable get() = stringResource(CoreR.string.title_changeDisplayName) override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnContactMethodsClicked } -internal data object LogOut : FullMenuItem() { +/** + * Toggle, not a destination — the screen renders a switch in its trailing slot and routes the tap + * through a biometric prompt. Its [action] is what a row tap dispatches, same as the switch. + */ +internal data object RequireBiometrics : FullMenuItem() { override val icon: Painter - @Composable get() = painterResource(R.drawable.ic_menu_logout) + @Composable get() = painterResource(R.drawable.ic_biometrics) override val name: String - @Composable get() = stringResource(R.string.action_logout) - override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnLogOutClicked + @Composable get() = stringResource(CoreR.string.title_requireBiometrics) + override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnBiometricsToggled } -internal data object DeleteAccount: FullMenuItem() { +internal data object Blocklist : FullMenuItem() { override val icon: Painter - @Composable get() = rememberVectorPainter(ImageVector.Delete) + @Composable get() = rememberVectorPainter(Icons.Outlined.Block) override val name: String - @Composable get() = stringResource(R.string.action_deleteAccount) - override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnDeleteAccountClicked -} \ No newline at end of file + @Composable get() = stringResource(R.string.title_blocklist) + override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnBlocklistClicked +} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenContent.kt index 99973aa36d..2ee25b6319 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenContent.kt @@ -3,9 +3,15 @@ package com.flipcash.app.myaccount.internal.myaccount import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.menu.MenuList +import com.getcode.libs.biometrics.Biometrics +import com.getcode.ui.components.ListItemDefaults +import com.getcode.ui.theme.CodeToggleSwitch +import kotlinx.coroutines.launch @Composable internal fun MyAccountScreen(viewModel: MyAccountScreenViewModel) { @@ -19,10 +25,37 @@ private fun MyAccountScreenContent( state: MyAccountScreenViewModel.State, dispatch: (MyAccountScreenViewModel.Event) -> Unit ) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + // Flipping the biometrics requirement has to be authenticated by the biometrics themselves, + // so the row (and its switch) route through a prompt before the toggle is dispatched. + val toggleBiometrics = { + if (state.biometricsAvailable) { + scope.launch { + Biometrics.prompt(context, delay = 300) + .onSuccess { dispatch(MyAccountScreenViewModel.Event.OnBiometricsToggled) } + } + } + Unit + } + MenuList( modifier = Modifier.fillMaxSize(), items = state.items, - showChevrons = true, - onItemClick = { dispatch(it.action) } + onItemClick = { item -> + if (item == RequireBiometrics) toggleBiometrics() else dispatch(item.action) + }, + endSlot = { item -> + if (item == RequireBiometrics) { + CodeToggleSwitch( + checked = state.biometricsRequired, + enabled = state.biometricsAvailable, + onCheckedChange = { toggleBiometrics() }, + ) + } else { + ListItemDefaults.Chevron() + } + } ) } 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 745d0500c5..8e6e4ff056 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 @@ -1,42 +1,27 @@ package com.flipcash.app.myaccount.internal.myaccount import androidx.lifecycle.viewModelScope -import com.flipcash.app.auth.AuthManager -import com.flipcash.app.featureflags.BetaFeature -import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.appsettings.AppSettingValue +import com.flipcash.app.appsettings.AppSettingsCoordinator 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 -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.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import javax.inject.Inject private val FullMenuList = buildList { - add(AccessKey) + add(DisplayName) + add(RequireBiometrics) add(Blocklist) - add(UserProfile) - add(LogOut) - add(DeleteAccount) } @HiltViewModel internal class MyAccountScreenViewModel @Inject constructor( - userManager: UserManager, - featureFlagController: FeatureFlagController, - resources: ResourceHelper, - authManager: AuthManager, + private val appSettings: AppSettingsCoordinator, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -44,78 +29,48 @@ internal class MyAccountScreenViewModel @Inject constructor( defaultDispatcher = dispatchers.Default, ) { internal data class State( - val isBetaEnabled: Boolean = false, - // Default hides staff-only AND flag-gated items until the real flag state loads, so a - // beta-gated item never flashes before its flag is resolved. - val items: List> = - FullMenuList.filterNot { it is StaffMenuItem || it.featureFlag != null } + val biometricsRequired: Boolean = false, + // Biometrics aren't offerable on every device: the row is hidden outright when the hardware + // isn't there, and shown-but-disabled when the hardware exists with nothing enrolled. + val biometricsSupported: Boolean = true, + val biometricsAvailable: Boolean = true, + val items: List> = FullMenuList, ) internal sealed interface Event { - data class OnBetaFeaturesUnlocked( - val unlocked: Boolean, - val flags: List = emptyList(), + data class OnBiometricsSettingChanged( + val required: Boolean, + val supported: Boolean, + val available: Boolean, ) : Event - data object OnAccessKeyClicked : Event + /** Dispatched only after the screen's biometric prompt succeeds. */ + data object OnBiometricsToggled : Event data object OnBlocklistClicked: Event - data object OnViewAccessKey : Event data object OnViewBlocklist: Event data object OnContactMethodsClicked : Event data object OnViewUserProfile : Event - data object OnDeleteAccountClicked : Event - data object OnAccountDeleted : Event - data object OnLogOutClicked : Event - data object OnLoggedOutCompletely : Event } init { - combine( - featureFlagController.observeOverride(), - userManager.state.map { it.flags?.isStaff == true }, - featureFlagController.observe(), - ) { override, isStaff, flags -> - dispatchEvent(Event.OnBetaFeaturesUnlocked(override || isStaff, flags)) - }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { - BottomBarManager.showAlert( - title = resources.getString(R.string.prompt_title_deleteAccount), - message = resources.getString(R.string.prompt_description_deleteAccount), - actions = listOf( - BottomBarAction(resources.getString(R.string.action_deleteAccount)) { - viewModelScope.launch { - delay(150) // wait for dismiss - authManager.deleteAndLogout() - .onSuccess { dispatchEvent(Event.OnAccountDeleted) } - .onFailure { - BottomBarManager.showError( - title = resources.getString(R.string.error_title_failedToDeleteAccount), - message = resources.getString(R.string.error_description_failedToDeleteAccount), - ) - } - } - } - ), - showCancel = true, + appSettings.settings() + .map { items -> items.find { it.setting.type == AppSettingValue.BiometricsRequired } } + .onEach { item -> + item ?: return@onEach + dispatchEvent( + Event.OnBiometricsSettingChanged( + required = item.setting.enabled, + supported = item.visible, + available = item.available, + ) ) }.launchIn(viewModelScope) eventFlow - .filterIsInstance() + .filterIsInstance() .onEach { - BottomBarManager.showAlert( - title = resources.getString(R.string.prompt_title_viewAccessKey), - message = resources.getString(R.string.prompt_description_viewAccessKey), - showScrim = true, - showCancel = true, - actions = listOf( - BottomBarAction( - text = resources.getString(R.string.action_viewAccessKey), - onClick = { dispatchEvent(Event.OnViewAccessKey) } - ) - ), + appSettings.update( + AppSettingValue.BiometricsRequired, + !stateFlow.value.biometricsRequired, ) }.launchIn(viewModelScope) @@ -130,72 +85,29 @@ internal class MyAccountScreenViewModel @Inject constructor( .onEach { dispatchEvent(Event.OnViewUserProfile) }.launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { - BottomBarManager.showAlert( - title = resources.getString(R.string.prompt_title_logout), - message = resources.getString(R.string.prompt_description_logout), - actions = listOf( - BottomBarAction(resources.getString(R.string.action_logout)) { - viewModelScope.launch { - delay(150) // wait for dismiss - authManager.logout() - .onSuccess { - dispatchEvent(Event.OnLoggedOutCompletely) - } - .onFailure { - BottomBarManager.showError( - title = resources.getString(R.string.error_title_failedToLogOut), - message = resources.getString(R.string.error_description_failedToLogOut), - ) - } - } - }, - ), - showCancel = true, - ) - }.launchIn(viewModelScope) } internal companion object { - private fun buildItemList( - isBetaEnabled: Boolean, - flags: List = emptyList(), - ): List> { - val base = if (isBetaEnabled) { - FullMenuList - } else { - FullMenuList.filterNot { item -> item is StaffMenuItem } - } - // Flag-gated items only show when their feature flag is enabled. - return base.filter { item -> - val flag = item.featureFlag ?: return@filter true - flags.find { it.flag.key == flag.key }?.enabled == true - } - } + private fun buildItemList(biometricsSupported: Boolean): List> = + FullMenuList.filterNot { it == RequireBiometrics && !biometricsSupported } val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { - Event.OnLogOutClicked, - Event.OnLoggedOutCompletely, + Event.OnBiometricsToggled, Event.OnContactMethodsClicked, Event.OnViewUserProfile, - Event.OnViewAccessKey, - Event.OnDeleteAccountClicked, - Event.OnAccountDeleted, - Event.OnAccessKeyClicked, Event.OnBlocklistClicked, Event.OnViewBlocklist -> { state -> state } - is Event.OnBetaFeaturesUnlocked -> { state -> + is Event.OnBiometricsSettingChanged -> { state -> state.copy( - isBetaEnabled = event.unlocked, - items = buildItemList(event.unlocked, event.flags) + biometricsRequired = event.required, + biometricsSupported = event.supported, + biometricsAvailable = event.available, + items = buildItemList(event.supported), ) } } } } -} \ No newline at end of file +} 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 7413e8d5ea..3763e7ec47 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 @@ -1,10 +1,9 @@ package com.flipcash.app.myaccount.internal -import com.flipcash.app.myaccount.internal.myaccount.AccessKey -import com.flipcash.app.myaccount.internal.myaccount.DeleteAccount -import com.flipcash.app.myaccount.internal.myaccount.LogOut +import com.flipcash.app.myaccount.internal.myaccount.Blocklist +import com.flipcash.app.myaccount.internal.myaccount.DisplayName import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreenViewModel -import com.flipcash.app.myaccount.internal.myaccount.UserProfile +import com.flipcash.app.myaccount.internal.myaccount.RequireBiometrics import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -15,59 +14,51 @@ class MyAccountScreenViewModelStateTest { private val reduce = MyAccountScreenViewModel.Companion.updateStateForEvent @Test - fun `default state has beta disabled`() { + fun `default state lists display name, biometrics and blocklist`() { val state = MyAccountScreenViewModel.State() - assertFalse(state.isBetaEnabled) + assertEquals(listOf(DisplayName, RequireBiometrics, Blocklist), state.items) + assertFalse(state.biometricsRequired) } @Test - fun `OnBetaFeaturesUnlocked true enables beta and shows ContactMethods item`() { + fun `unsupported biometrics hides the row`() { val updated = reduce( - MyAccountScreenViewModel.Event.OnBetaFeaturesUnlocked(true) + MyAccountScreenViewModel.Event.OnBiometricsSettingChanged( + required = false, + supported = false, + available = false, + ) )(MyAccountScreenViewModel.State()) - assertTrue(updated.isBetaEnabled) - assertTrue(updated.items.any { it is UserProfile }) - } - @Test - 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 UserProfile }) + assertFalse(updated.items.any { it is RequireBiometrics }) + assertTrue(updated.items.any { it is DisplayName }) + assertTrue(updated.items.any { it is Blocklist }) } @Test - fun `menu always contains AccessKey LogOut and DeleteAccount`() { - val withBeta = reduce( - MyAccountScreenViewModel.Event.OnBetaFeaturesUnlocked(true) + fun `enrolled biometrics keeps the row and mirrors the setting`() { + val updated = reduce( + MyAccountScreenViewModel.Event.OnBiometricsSettingChanged( + required = true, + supported = true, + available = true, + ) )(MyAccountScreenViewModel.State()) - assertTrue(withBeta.items.any { it is AccessKey }) - assertTrue(withBeta.items.any { it is LogOut }) - assertTrue(withBeta.items.any { it is DeleteAccount }) - val withoutBeta = reduce( - MyAccountScreenViewModel.Event.OnBetaFeaturesUnlocked(false) - )(MyAccountScreenViewModel.State()) - assertTrue(withoutBeta.items.any { it is AccessKey }) - assertTrue(withoutBeta.items.any { it is LogOut }) - assertTrue(withoutBeta.items.any { it is DeleteAccount }) + assertTrue(updated.items.any { it is RequireBiometrics }) + assertTrue(updated.biometricsRequired) + assertTrue(updated.biometricsAvailable) } @Test fun `no-op events return state unchanged`() { - val state = MyAccountScreenViewModel.State(isBetaEnabled = true) + val state = MyAccountScreenViewModel.State(biometricsRequired = true) val noOpEvents = listOf( - MyAccountScreenViewModel.Event.OnLogOutClicked, - MyAccountScreenViewModel.Event.OnLoggedOutCompletely, + MyAccountScreenViewModel.Event.OnBiometricsToggled, MyAccountScreenViewModel.Event.OnContactMethodsClicked, MyAccountScreenViewModel.Event.OnViewUserProfile, - MyAccountScreenViewModel.Event.OnViewAccessKey, - MyAccountScreenViewModel.Event.OnDeleteAccountClicked, - MyAccountScreenViewModel.Event.OnAccountDeleted, - MyAccountScreenViewModel.Event.OnAccessKeyClicked, + MyAccountScreenViewModel.Event.OnBlocklistClicked, + MyAccountScreenViewModel.Event.OnViewBlocklist, ) noOpEvents.forEach { event -> assertEquals(state, reduce(event)(state), "Event $event should be no-op") diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt index e7f220220c..cc69bd8bf0 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt @@ -76,15 +76,6 @@ internal fun ScannableContainer( val state by session.state.collectAsStateWithLifecycle() val billState by session.billState.collectAsStateWithLifecycle() - val autoStart = state.autoStartCamera == true - var cameraStarted by remember { mutableStateOf(autoStart) } - - OnLifecycleEvent { _, event -> - if (event == Lifecycle.Event.ON_STOP && !autoStart) { - cameraStarted = false - } - } - Box( modifier = Modifier .fillMaxSize() @@ -115,15 +106,7 @@ internal fun ScannableContainer( onClick = { cameraPermission.launch() } ) } - PermissionResult.Granted -> { - if (!cameraStarted) { - CameraDisabledView(modifier = Modifier.fillMaxSize()) { - cameraStarted = true - } - } else { - scannerView() - } - } + PermissionResult.Granted -> scannerView() PermissionResult.PermanentlyDenied -> { CameraDisabledView(modifier = Modifier.fillMaxSize()) { context.launchAppSettings() diff --git a/apps/flipcash/shared/appsettings/src/main/kotlin/com/flipcash/app/appsettings/AppSettingValue.kt b/apps/flipcash/shared/appsettings/src/main/kotlin/com/flipcash/app/appsettings/AppSettingValue.kt index 4da1800554..3e5da72b89 100644 --- a/apps/flipcash/shared/appsettings/src/main/kotlin/com/flipcash/app/appsettings/AppSettingValue.kt +++ b/apps/flipcash/shared/appsettings/src/main/kotlin/com/flipcash/app/appsettings/AppSettingValue.kt @@ -7,17 +7,11 @@ sealed interface AppSettingValue { companion object { val entries: List by lazy { listOf( - CameraStartByDefault, BiometricsRequired ) } } - data object CameraStartByDefault: AppSettingValue { - override val key: String = "camera_start_default" - override val default: Boolean = true - } - data object BiometricsRequired: AppSettingValue { override val key: String = "require_biometrics" } diff --git a/apps/flipcash/shared/appsettings/src/main/kotlin/com/flipcash/app/appsettings/internal/AppSettingsMapper.kt b/apps/flipcash/shared/appsettings/src/main/kotlin/com/flipcash/app/appsettings/internal/AppSettingsMapper.kt index 56bb6ef861..0183a209cb 100644 --- a/apps/flipcash/shared/appsettings/src/main/kotlin/com/flipcash/app/appsettings/internal/AppSettingsMapper.kt +++ b/apps/flipcash/shared/appsettings/src/main/kotlin/com/flipcash/app/appsettings/internal/AppSettingsMapper.kt @@ -35,13 +35,6 @@ class AppSettingMapper @Inject constructor( ) } - AppSettingValue.CameraStartByDefault -> { - AppSettingsItem( - setting = from, - name = R.string.title_autoStartCamera, - icon = R.drawable.ic_camera_outline, - ) - } } } } \ No newline at end of file diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/cards/TipCard.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/cards/TipCard.kt index d82b1ce09c..111aea46a8 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/cards/TipCard.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/cards/TipCard.kt @@ -67,7 +67,9 @@ private const val TipCardAspectRatio = 333f / 269f * on the scanner/camera). Capped at [TipCardMaxWidth]. Mirrors iOS `BillCanvas.tipcardSize`. */ private const val TipCardCanvasWidthFraction = 0.82f -private val TipCardMaxWidth: Dp = 270.dp +// Sized so a phone-width canvas gets the full 0.82 fraction (node 9277:121417 puts the full-screen +// card at 302 on a 402 frame); the cap is really there to stop a tablet blowing the card up. +private val TipCardMaxWidth: Dp = 305.dp // The card derives its inner metrics from its width, matching iOS `TipcardView`. private const val TipCardCodeFraction = 0.68f // scannable code (square) diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/ScannableDecorator.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/ScannableDecorator.kt index fc81970526..492eb59e57 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/ScannableDecorator.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/ScannableDecorator.kt @@ -59,9 +59,9 @@ sealed interface ScannableDecorator { /** Resolves the decor that own the below-bill content for [scannable]. */ fun forScannable(scannable: Scannable): ScannableDecorator = when (scannable) { is Scannable.Payable -> PayableDecorator(scannable) - // The viewer's own tip card (e.g. the You tab's full-screen present) has no below-bill - // content — no Send-a-Tip modal, no add-money prompt. You can't tip yourself. - is Scannable.TipCard -> if (scannable.isSelf) NoOpScannableDecorator else TipCardDecorator(scannable) + // The viewer's own tip card (the You tab's full-screen present) gets no Send-a-Tip modal + // and no add-money prompt — you can't tip yourself — just a Close affordance. + is Scannable.TipCard -> if (scannable.isSelf) SelfTipCardDecorator else TipCardDecorator(scannable) } } } diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/SelfTipCardDecorator.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/SelfTipCardDecorator.kt new file mode 100644 index 0000000000..8262cf749c --- /dev/null +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/SelfTipCardDecorator.kt @@ -0,0 +1,66 @@ +package com.flipcash.app.bills.decor + +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.rotate +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.bill.Scannable +import com.flipcash.shared.bills.R +import com.getcode.theme.CodeTheme +import com.getcode.theme.White50 +import com.getcode.ui.core.noRippleClickable + +/** + * Decor for the viewer's OWN tip card, presented full screen from the You tab (node 9277:121410). + * + * There's no tip modal here — you can't tip yourself — so the only below-card content is the + * "Close" affordance that mirrors the "Full Screen" one the card was opened from. Swipe still + * dismisses; this just makes that discoverable. + */ +internal data object SelfTipCardDecorator : ScannableDecorator { + @Composable + override fun BoxScope.Content(context: ScannableDecoratorContext) { + AnimatedScannableDecorator( + visible = context.liveBill is Scannable.TipCard, + enter = fadeIn(tween(durationMillis = 200)), + exit = fadeOut(tween(durationMillis = 200)), + modifier = Modifier.align(Alignment.BottomCenter), + ) { + Row( + modifier = Modifier + .navigationBarsPadding() + .padding(bottom = 40.dp) + .noRippleClickable { context.onDismiss() }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Text( + text = stringResource(R.string.action_closeFullScreen), + style = CodeTheme.typography.textSmall, + color = White50, + ) + Icon( + // Same glyph as "Full Screen ⌄", flipped — the affordance reverses. + modifier = Modifier.size(16.dp).rotate(180f), + painter = painterResource(R.drawable.ic_chevron_down_medium), + contentDescription = null, + tint = White50, + ) + } + } + } +} diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt index af8328ef6b..27fde4cc40 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt @@ -66,7 +66,6 @@ data class SessionState( val hasBalance: Boolean = false, val logScanTimes: Boolean = false, val showNetworkOffline: Boolean = false, - val autoStartCamera: Boolean? = true, val isCameraUp: Boolean? = null, val billResult: BillDeterminationResult = BillDeterminationResult.None, val restrictionType: RestrictionType? = null, diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index 6522289d0c..5d72de19be 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -239,11 +239,6 @@ class RealSessionController @Inject constructor( .onEach { blobStorageCoordinator.preloadPolicy() } .launchIn(scope) - appSettingsCoordinator - .observeValue(AppSettingValue.CameraStartByDefault) - .onEach { autoStart -> stateHolder.update { it.copy(autoStartCamera = autoStart) } } - .launchIn(scope) - featureFlagController.observe(FeatureFlag.ShowNetworkState) .onEach { enabled -> stateHolder.update { it.copy(showNetworkOffline = enabled) } } .launchIn(scope) diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt index be6fb50d89..a621216c95 100644 --- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt +++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt @@ -2,6 +2,7 @@ package com.flipcash.app.shareable import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.staticCompositionLocalOf +import com.flipcash.app.core.share.TipCodeExport import com.flipcash.app.core.share.TipCodePreview import com.getcode.ed25519.Ed25519 import com.getcode.opencode.model.accounts.GiftCardAccount @@ -52,6 +53,21 @@ sealed interface Shareable { ): Shareable { override val pendingData: ShareablePendingData? = null } + + /** + * The tip code itself, as a file (see [TipCodeExport]) — what the "Download" action produces. + * + * Distinct from [TipCard]: that shares the tip *link* and only uses an image as the Sharesheet's + * thumbnail. Here the file IS the payload, so it goes out as an `EXTRA_STREAM` of the export's + * own MIME type and a "save to Files" target receives something real. + */ + data class TipCodeImage( + val export: TipCodeExport, + // Optional Sharesheet title shown above the file. + val title: String? = null, + ): Shareable { + override val pendingData: ShareablePendingData? = null + } } sealed interface ShareablePendingData { diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareConfirmationController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareConfirmationController.kt index 896893b07c..b521a369fc 100644 --- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareConfirmationController.kt +++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareConfirmationController.kt @@ -29,6 +29,7 @@ internal class InternalShareConfirmationController( is Shareable.TokenInfo -> ShareConfirmationResult.Confirmed(shareResult) is Shareable.Invite -> ShareConfirmationResult.Confirmed(shareResult) is Shareable.TipCard -> ShareConfirmationResult.Confirmed(shareResult) + is Shareable.TipCodeImage -> ShareConfirmationResult.Confirmed(shareResult) } } diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt index 5b95d2dc40..4c4fdf6150 100644 --- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt +++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt @@ -103,6 +103,7 @@ internal class InternalShareSheetController( is Shareable.TokenInfo -> Unit is Shareable.Invite -> Unit is Shareable.TipCard -> Unit + is Shareable.TipCodeImage -> Unit } } } @@ -149,6 +150,8 @@ internal class InternalShareSheetController( } is Shareable.TipCard -> shareTipCard(shareable) + + is Shareable.TipCodeImage -> shareTipCodeImage(shareable) } } @@ -321,6 +324,36 @@ internal class InternalShareSheetController( context.startActivity(share) } + /** + * Shares the exported code file itself (PNG/SVG). Unlike [shareTipCard], the payload here IS the + * file: it goes out as an `EXTRA_STREAM` of the export's own MIME type, so "save to Files" and + * image-consuming targets receive something real rather than a link. + */ + private fun shareTipCodeImage(shareable: Shareable.TipCodeImage) { + val export = shareable.export + + val intent = Intent(Intent.ACTION_SEND).apply { + type = export.mimeType + putExtra(Intent.EXTRA_STREAM, export.uri) + shareable.title?.let { + putExtra(Intent.EXTRA_TITLE, it) + putExtra(Intent.EXTRA_SUBJECT, it) + } + // Also as ClipData so the Sharesheet can draw a preview of the PNG (and so the read + // grant travels with the intent). + clipData = ClipData.newUri(context.contentResolver, shareable.title.orEmpty(), export.uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + val share = Intent.createChooser(intent, null).apply { + // addFlags, not `flags =` — see shareTipCard: assigning would wipe the migrated grant. + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + context.startActivity(share) + } + override fun reset(setChecked: Boolean) { pendingShareable = null sharedWithApp = null diff --git a/settings.gradle.kts b/settings.gradle.kts index 3a46883b8e..d1059fcbe0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -102,7 +102,6 @@ include( ":apps:flipcash:features:purchase", ":apps:flipcash:features:lab", ":apps:flipcash:features:home", - ":apps:flipcash:features:appsettings", ":apps:flipcash:features:appupdates", ":apps:flipcash:features:deposit", ":apps:flipcash:features:advanced", 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 30e20298e6..cc1dc8337e 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 @@ -5,8 +5,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer 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.foundation.layout.wrapContentHeight import androidx.compose.material3.HorizontalDivider @@ -18,11 +18,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.getcode.theme.CodeTheme +import com.getcode.theme.White20 import androidx.compose.foundation.clickable +private val ListItemIconSize = 24.dp + +/** 17sp Demi, per the settings rows in node 9276:4634 — not the 20sp of a section headline. */ +private val ListItemHeadlineStyle + @Composable get() = CodeTheme.typography.textMedium.copy(fontSize = 17.sp, lineHeight = 22.sp) + /** * Slot-based list row: icon + headline, with the caller driving the trailing [endSlot] — chevron, * loading spinner, beta badge, or any combination. Prefer this overload when the trailing content @@ -47,9 +54,8 @@ fun ListItem( if (icon != null) { Image( modifier = Modifier - .padding(end = CodeTheme.dimens.inset) - .height(CodeTheme.dimens.staticGrid.x5) - .width(CodeTheme.dimens.staticGrid.x5), + .padding(end = CodeTheme.dimens.grid.x4) + .size(ListItemIconSize), painter = icon, colorFilter = ColorFilter.tint(CodeTheme.colors.onBackground), contentDescription = "" @@ -59,9 +65,7 @@ fun ListItem( Text( modifier = Modifier.align(CenterVertically), text = headline, - style = CodeTheme.typography.textLarge.copy( - fontWeight = FontWeight.Bold - ), + style = ListItemHeadlineStyle, color = CodeTheme.colors.textMain, ) @@ -104,11 +108,19 @@ fun ListItem( } if (showChevron) { - Icon( - painter = painterResource(id = R.drawable.ic_chevron_right), - contentDescription = null, - tint = CodeTheme.colors.textSecondary, - ) + ListItemDefaults.Chevron() } } } + +object ListItemDefaults { + /** The standard trailing disclosure chevron; also for callers driving their own [endSlot]. */ + @Composable + fun Chevron() { + Icon( + painter = painterResource(id = R.drawable.ic_chevron_right), + contentDescription = null, + tint = White20, + ) + } +} diff --git a/ui/resources/src/main/res/drawable/ic_bezier_curve.xml b/ui/resources/src/main/res/drawable/ic_bezier_curve.xml new file mode 100644 index 0000000000..1fe914cdc1 --- /dev/null +++ b/ui/resources/src/main/res/drawable/ic_bezier_curve.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + diff --git a/ui/resources/src/main/res/drawable/ic_chain_link.xml b/ui/resources/src/main/res/drawable/ic_chain_link.xml new file mode 100644 index 0000000000..1a8176b646 --- /dev/null +++ b/ui/resources/src/main/res/drawable/ic_chain_link.xml @@ -0,0 +1,14 @@ + + + + diff --git a/ui/resources/src/main/res/drawable/ic_chevron_down_medium.xml b/ui/resources/src/main/res/drawable/ic_chevron_down_medium.xml new file mode 100644 index 0000000000..53eaf597c2 --- /dev/null +++ b/ui/resources/src/main/res/drawable/ic_chevron_down_medium.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/resources/src/main/res/drawable/ic_copy.xml b/ui/resources/src/main/res/drawable/ic_copy.xml new file mode 100644 index 0000000000..100b5c00a3 --- /dev/null +++ b/ui/resources/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,12 @@ + + + diff --git a/ui/resources/src/main/res/drawable/ic_file_bend.xml b/ui/resources/src/main/res/drawable/ic_file_bend.xml new file mode 100644 index 0000000000..4bd1993823 --- /dev/null +++ b/ui/resources/src/main/res/drawable/ic_file_bend.xml @@ -0,0 +1,17 @@ + + + + diff --git a/ui/resources/src/main/res/drawable/ic_file_download.xml b/ui/resources/src/main/res/drawable/ic_file_download.xml new file mode 100644 index 0000000000..c3f27c5f95 --- /dev/null +++ b/ui/resources/src/main/res/drawable/ic_file_download.xml @@ -0,0 +1,13 @@ + + + + diff --git a/ui/resources/src/main/res/drawable/ic_maintenance.xml b/ui/resources/src/main/res/drawable/ic_maintenance.xml new file mode 100644 index 0000000000..92e480342f --- /dev/null +++ b/ui/resources/src/main/res/drawable/ic_maintenance.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/resources/src/main/res/drawable/ic_people_id_card.xml b/ui/resources/src/main/res/drawable/ic_people_id_card.xml new file mode 100644 index 0000000000..267849fcd6 --- /dev/null +++ b/ui/resources/src/main/res/drawable/ic_people_id_card.xml @@ -0,0 +1,10 @@ + + + diff --git a/ui/resources/src/main/res/drawable/ic_share_os.xml b/ui/resources/src/main/res/drawable/ic_share_os.xml new file mode 100644 index 0000000000..3f38631c38 --- /dev/null +++ b/ui/resources/src/main/res/drawable/ic_share_os.xml @@ -0,0 +1,24 @@ + + + + + From 4d0d8bde1460cece7f5a2217d70126336bb0952f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 21:50:38 -0400 Subject: [PATCH 2/8] fix(you): unclip tile labels, level the toggle row, own-screen tip card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes found on device: * The Share / Download tiles paid 20dp of vertical padding inside a tile whose height is already fixed at 88dp, leaving the label a 14dp box for a 16dp line and clipping its descenders. The centred arrangement was already doing the spacing, so the padding just goes. * "Require Biometrics" sat ~24dp taller than its neighbours: Material applies its 48dp minimum touch target to a Switch only when onCheckedChange is non-null, and 48dp beats the 24dp icon that sets every other row's height. Adds ListItemDefaults.Toggle — a display-only switch pinned to the icon size — and uses it there; the row's own onClick already ran the toggle, and it still does when you tap the switch itself. * The full-screen tip card is now a route (AppRoute.Menu.TipCard) instead of a bill presented into the session's overlay. The card is a static thing to hold up to a camera, so it gets the whole screen: the page drops away downward on push (and the tab bar leaves with it, since this isn't a tab route), the card sits centred at the design's 302/402 of the screen width, and Close sits just above the navigation bar. Node 9277:121410. That last one retires the whole own-card-as-bill path: presentOwnTipCard, Scannable.TipCard.isSelf, and SelfTipCardDecorator. --- .../app/internal/ui/navigation/AppContent.kt | 19 +++ .../ui/navigation/AppScreenContent.kt | 2 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 4 + .../com/flipcash/app/core/bill/Scannable.kt | 6 - apps/flipcash/features/menu/build.gradle.kts | 1 - .../com/flipcash/app/menu/TipCardScreen.kt | 15 +++ .../app/menu/internal/MenuScreenContent.kt | 34 ++---- .../internal/tipcard/TipCardScreenContent.kt | 111 ++++++++++++++++++ .../tipcard/TipCardScreenViewModel.kt | 32 +++++ .../myaccount/MyAccountScreenContent.kt | 7 +- .../app/bills/decor/ScannableDecorator.kt | 2 +- .../app/bills/decor/SelfTipCardDecorator.kt | 66 ----------- .../flipcash/app/session/SessionController.kt | 7 -- .../delegates/BillPresentationDelegate.kt | 9 -- .../internal/delegates/TipCardDelegate.kt | 4 +- .../com/getcode/ui/components/ListItem.kt | 20 ++++ 16 files changed, 220 insertions(+), 119 deletions(-) create mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/TipCardScreen.kt create mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/tipcard/TipCardScreenContent.kt create mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/tipcard/TipCardScreenViewModel.kt delete mode 100644 apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/SelfTipCardDecorator.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index 173d38db0a..e3a743d601 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt @@ -6,7 +6,9 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -63,6 +65,8 @@ private fun isTokenInfoKey(key: Any?): Boolean { private fun isGiveKey(key: Any?): Boolean = key?.toString()?.startsWith("Give(") == true +private fun isSelfTipCardKey(key: Any?): Boolean = key is AppRoute.Menu.TipCard + @Composable internal fun AppContent( codeNavigator: CodeNavigator, @@ -228,6 +232,13 @@ internal fun NewAppContent( // CardExpandTransition + TokenCardStack. isTokenInfoKey(targetState.key) -> CardExpandTransition.openEnter togetherWith CardExpandTransition.openExit + // The You tab's own tip card, full screen: the card is already on screen, + // so nothing slides in over it — the page (and, because this isn't a tab + // route, the tab bar) drops away downward and leaves it, matching the + // "Full Screen ⌄" chevron that opened it. + isSelfTipCardKey(targetState.key) -> + fadeIn(tween(300)) togetherWith + (slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(300))) landsOnTab -> fadeIn(tween(300)) togetherWith fadeOut(tween(300)) else -> @@ -248,6 +259,11 @@ internal fun NewAppContent( // Swapping in a single frame keeps the scrim continuously up. isGiveKey(initialState.key) -> EnterTransition.None togetherWith ExitTransition.None + // Reverse of the open: the page rides back up over the card. See the + // matching branch in transitionSpec. + isSelfTipCardKey(initialState.key) -> + (slideInVertically(initialOffsetY = { it }) + fadeIn(tween(300))) togetherWith + fadeOut(tween(300)) else -> slideInHorizontally(initialOffsetX = { -it }) togetherWith slideOutHorizontally(targetOffsetX = { it }) @@ -261,6 +277,9 @@ internal fun NewAppContent( isTokenInfoKey(initialState.key) -> CardExpandTransition.predictiveCloseEnter togetherWith CardExpandTransition.predictiveCloseExit + isSelfTipCardKey(initialState.key) -> + (slideInVertically(initialOffsetY = { it }) + fadeIn(tween(300))) togetherWith + fadeOut(tween(300)) else -> slideInHorizontally(initialOffsetX = { -it }) togetherWith slideOutHorizontally(targetOffsetX = { it }) 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 80cbc8f3c9..e63ea9ab3d 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 @@ -41,6 +41,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.menu.TipCardScreen import com.flipcash.app.myaccount.BlocklistScreen import com.flipcash.app.myaccount.UserProfileScreen import com.flipcash.app.myaccount.MyAccountScreen @@ -148,6 +149,7 @@ fun appEntryProvider( annotatedEntry { key -> LabsScreen(onboarding = key.onboarding) } annotatedEntry { NavBarSettingsScreen() } annotatedEntry { UserProfileScreen() } + annotatedEntry { TipCardScreen() } annotatedEntry { MyAccountScreen() } annotatedEntry { BlocklistScreen() } annotatedEntry { BackupKeyScreen() } 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 729de1e98a..5be4d04aa8 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 @@ -307,6 +307,10 @@ sealed interface AppRoute : NavKey, Parcelable { data object DeviceLogs : Menu @Serializable data object UserProfile : Menu + + /** The viewer's own tip card, full screen (node 9277:121410). */ + @Serializable + data object TipCard : Menu @Serializable data class Lab(val onboarding: Boolean = false) : Menu @Serializable diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/bill/Scannable.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/bill/Scannable.kt index a627a21cfe..1a3a53280d 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/bill/Scannable.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/bill/Scannable.kt @@ -91,11 +91,5 @@ sealed interface Scannable { data class TipCard( override val data: List, val user: UserProfile, - /** - * True when this is the viewer's *own* tip card, presented for display (e.g. the You tab's - * full-screen card) rather than scanned from someone else. Suppresses the Send-a-Tip modal - * and its add-money prompt — you can't tip yourself. - */ - val isSelf: Boolean = false, ) : Scannable } diff --git a/apps/flipcash/features/menu/build.gradle.kts b/apps/flipcash/features/menu/build.gradle.kts index 66aece0b5f..c3c31b4986 100644 --- a/apps/flipcash/features/menu/build.gradle.kts +++ b/apps/flipcash/features/menu/build.gradle.kts @@ -14,7 +14,6 @@ dependencies { implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:menu")) implementation(project(":apps:flipcash:shared:funding")) - implementation(project(":apps:flipcash:shared:session")) implementation(project(":apps:flipcash:shared:shareable")) implementation(project(":apps:flipcash:shared:tipping")) implementation(project(":apps:flipcash:shared:userflags")) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/TipCardScreen.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/TipCardScreen.kt new file mode 100644 index 0000000000..17d4361613 --- /dev/null +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/TipCardScreen.kt @@ -0,0 +1,15 @@ +package com.flipcash.app.menu + +import androidx.compose.runtime.Composable +import androidx.hilt.navigation.compose.hiltViewModel +import com.flipcash.app.menu.internal.tipcard.TipCardScreenContent +import com.flipcash.app.menu.internal.tipcard.TipCardScreenViewModel +import com.getcode.navigation.core.LocalCodeNavigator + +@Composable +fun TipCardScreen() { + val viewModel = hiltViewModel() + val navigator = LocalCodeNavigator.current + + TipCardScreenContent(viewModel = viewModel, onClose = { navigator.pop() }) +} 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 d7e7e1d182..38a1efda26 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 @@ -1,7 +1,5 @@ package com.flipcash.app.menu.internal -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -33,7 +31,6 @@ 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.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -52,7 +49,6 @@ import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.menu.MenuList import com.flipcash.app.menu.internal.MenuScreenViewModel.Event -import com.flipcash.app.session.LocalSessionController import com.flipcash.app.updates.LocalAppUpdater import com.flipcash.features.menu.R import com.getcode.navigation.core.CodeNavigator @@ -177,9 +173,7 @@ private val YouCardWidth = 242.dp /** * The "You" tab header (node 9276:4634): the viewer's own tip card with a "Full Screen" affordance, - * the copyable tip link, and the Share / Download tiles. The in-page card fades out while its - * expanded copy is presented in the root bill overlay (opacity, not removal, so nothing reflows on - * dismiss). + * the copyable tip link, and the Share / Download tiles. */ @OptIn(ExperimentalLayoutApi::class) @Composable @@ -191,14 +185,8 @@ private fun YouHeader( onDownload: () -> Unit, ) { if (card == null) return - val session = LocalSessionController.current ?: return - val billState by session.billState.collectAsStateWithLifecycle() - val presented = billState.bill is Scannable.TipCard - val cardAlpha by animateFloatAsState( - targetValue = if (presented) 0f else 1f, - animationSpec = tween(durationMillis = 200), - label = "youCardAlpha", - ) + val navigator = LocalCodeNavigator.current + val openFullScreen = { navigator.push(AppRoute.Menu.TipCard) } Column( modifier = Modifier.fillMaxWidth(), @@ -216,8 +204,7 @@ private fun YouHeader( // list's content padding already owns that clearance, so consume the inset // rather than paying it twice. .consumeWindowInsets(WindowInsets.statusBarsIgnoringVisibility) - .graphicsLayer { alpha = cardAlpha } - .noRippleClickable { session.presentOwnTipCard(card) }, + .noRippleClickable { openFullScreen() }, contentAlignment = Alignment.Center, ) { ScannableRenderer(scannable = card, tipCardWidth = YouCardWidth) @@ -227,9 +214,7 @@ private fun YouHeader( Spacer(Modifier.height(CodeTheme.dimens.grid.x6)) Row( - modifier = Modifier - .graphicsLayer { alpha = cardAlpha } - .noRippleClickable { session.presentOwnTipCard(card) }, + modifier = Modifier.noRippleClickable { openFullScreen() }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp), ) { @@ -325,7 +310,11 @@ private fun TipLinkRow(link: String, onCopy: () -> Unit) { } } -/** One of the two square-ish actions under the link (node 9276:4756). */ +/** + * One of the two square-ish actions under the link (node 9276:4756). The tile's height is fixed by + * the parent row and the arrangement centres its contents, so it takes no vertical padding of its + * own: 20dp of it on each side left the label a 14dp box for a 16dp line and clipped its descenders. + */ @Composable private fun ShareTile( modifier: Modifier = Modifier, @@ -338,8 +327,7 @@ private fun ShareTile( .fillMaxSize() .clip(TileShape) .background(White05) - .clickable { onClick() } - .padding(vertical = CodeTheme.dimens.grid.x4), + .clickable { onClick() }, verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally, ) { diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/tipcard/TipCardScreenContent.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/tipcard/TipCardScreenContent.kt new file mode 100644 index 0000000000..fece5b950c --- /dev/null +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/tipcard/TipCardScreenContent.kt @@ -0,0 +1,111 @@ +package com.flipcash.app.menu.internal.tipcard + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsIgnoringVisibility +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.bills.ScannableRenderer +import com.flipcash.app.bills.components.cards.LocalTipCardBaseAlpha +import com.flipcash.app.bills.components.cards.LocalTipCardColor +import com.flipcash.features.menu.R +import com.getcode.theme.CodeTheme +import com.getcode.theme.White50 +import com.getcode.ui.core.noRippleClickable + +/** + * The viewer's own tip card, full screen (node 9277:121410). + * + * A route rather than a presented bill: the card is a static thing to hold up to a camera, so it + * gets the whole screen — the You tab's content and the tab bar leave with the push (the tab bar + * hides itself on any non-tab route) instead of the card competing with them under a scrim. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun TipCardScreenContent(viewModel: TipCardScreenViewModel, onClose: () -> Unit) { + val card by viewModel.card.collectAsStateWithLifecycle() + + Box( + modifier = Modifier + .fillMaxSize() + .noRippleClickable { onClose() }, + ) { + if (card != null) { + // Static display, no camera behind it: opaque at the design's flattened colour rather + // than the translucent frosted fill. Figma flattens the card to rgb(16,16,17). + CompositionLocalProvider( + LocalTipCardColor provides Color(0xFF101011), + LocalTipCardBaseAlpha provides 1f, + ) { + ScannableRenderer( + // The card pads itself off the status bar for its in-scanner placement; here it + // is centred on the whole screen (the design centres it on the full frame), so + // consume that inset instead of paying it. + modifier = Modifier + .fillMaxSize() + .consumeWindowInsets(WindowInsets.statusBarsIgnoringVisibility), + scannable = requireNotNull(card), + tipCardWidth = fullScreenCardWidth(), + ) + } + } + + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(bottom = CodeTheme.dimens.grid.x2) + .noRippleClickable { onClose() }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Text( + text = stringResource(R.string.action_closeFullScreen), + style = CodeTheme.typography.textSmall, + color = White50, + ) + Icon( + // Same glyph as the "Full Screen ⌄" this was opened from, flipped — the affordance + // reverses. + modifier = Modifier + .size(16.dp) + .rotate(180f), + painter = painterResource(R.drawable.ic_chevron_down_medium), + contentDescription = null, + tint = White50, + ) + } + } +} + +/** + * Node 9277:121410 puts the card at 302.21 on a 402-wide frame, so it scales with the device rather + * than sitting at a fixed width. The cap only exists to stop a tablet blowing it up past the size a + * phone camera expects to read. + */ +@Composable +private fun fullScreenCardWidth(): Dp = + minOf(CodeTheme.dimens.screenWidth * FullScreenCardWidthFraction, FullScreenCardMaxWidth) + +private const val FullScreenCardWidthFraction = 302.21f / 402f +private val FullScreenCardMaxWidth: Dp = 360.dp diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/tipcard/TipCardScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/tipcard/TipCardScreenViewModel.kt new file mode 100644 index 0000000000..1280e463e8 --- /dev/null +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/tipcard/TipCardScreenViewModel.kt @@ -0,0 +1,32 @@ +package com.flipcash.app.menu.internal.tipcard + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flipcash.app.core.bill.Scannable +import com.flipcash.shared.tipping.TippingCoordinator +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Resolves the viewer's own tip card for the full-screen presentation. The coordinator builds it + * from the cached profile, so this settles within a frame or two of the screen appearing — the + * nullable state is there for that gap, not for a real load. + */ +@HiltViewModel +internal class TipCardScreenViewModel @Inject constructor( + tippingCoordinator: TippingCoordinator, +) : ViewModel() { + + private val _card = MutableStateFlow(null) + val card: StateFlow = _card.asStateFlow() + + init { + viewModelScope.launch { + tippingCoordinator.resolveTipCard().onSuccess { _card.value = it } + } + } +} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenContent.kt index 2ee25b6319..0ab8b6b744 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenContent.kt @@ -10,7 +10,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.menu.MenuList import com.getcode.libs.biometrics.Biometrics import com.getcode.ui.components.ListItemDefaults -import com.getcode.ui.theme.CodeToggleSwitch import kotlinx.coroutines.launch @Composable @@ -29,7 +28,8 @@ private fun MyAccountScreenContent( val scope = rememberCoroutineScope() // Flipping the biometrics requirement has to be authenticated by the biometrics themselves, - // so the row (and its switch) route through a prompt before the toggle is dispatched. + // so the row routes through a prompt before the toggle is dispatched. The switch is display + // only; tapping anywhere on the row (the switch included) runs this. val toggleBiometrics = { if (state.biometricsAvailable) { scope.launch { @@ -48,10 +48,9 @@ private fun MyAccountScreenContent( }, endSlot = { item -> if (item == RequireBiometrics) { - CodeToggleSwitch( + ListItemDefaults.Toggle( checked = state.biometricsRequired, enabled = state.biometricsAvailable, - onCheckedChange = { toggleBiometrics() }, ) } else { ListItemDefaults.Chevron() diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/ScannableDecorator.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/ScannableDecorator.kt index 492eb59e57..fb4c468ec4 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/ScannableDecorator.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/ScannableDecorator.kt @@ -61,7 +61,7 @@ sealed interface ScannableDecorator { is Scannable.Payable -> PayableDecorator(scannable) // The viewer's own tip card (the You tab's full-screen present) gets no Send-a-Tip modal // and no add-money prompt — you can't tip yourself — just a Close affordance. - is Scannable.TipCard -> if (scannable.isSelf) SelfTipCardDecorator else TipCardDecorator(scannable) + is Scannable.TipCard -> TipCardDecorator(scannable) } } } diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/SelfTipCardDecorator.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/SelfTipCardDecorator.kt deleted file mode 100644 index 8262cf749c..0000000000 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/SelfTipCardDecorator.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.flipcash.app.bills.decor - -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -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.rotate -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.flipcash.app.core.bill.Scannable -import com.flipcash.shared.bills.R -import com.getcode.theme.CodeTheme -import com.getcode.theme.White50 -import com.getcode.ui.core.noRippleClickable - -/** - * Decor for the viewer's OWN tip card, presented full screen from the You tab (node 9277:121410). - * - * There's no tip modal here — you can't tip yourself — so the only below-card content is the - * "Close" affordance that mirrors the "Full Screen" one the card was opened from. Swipe still - * dismisses; this just makes that discoverable. - */ -internal data object SelfTipCardDecorator : ScannableDecorator { - @Composable - override fun BoxScope.Content(context: ScannableDecoratorContext) { - AnimatedScannableDecorator( - visible = context.liveBill is Scannable.TipCard, - enter = fadeIn(tween(durationMillis = 200)), - exit = fadeOut(tween(durationMillis = 200)), - modifier = Modifier.align(Alignment.BottomCenter), - ) { - Row( - modifier = Modifier - .navigationBarsPadding() - .padding(bottom = 40.dp) - .noRippleClickable { context.onDismiss() }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp), - ) { - Text( - text = stringResource(R.string.action_closeFullScreen), - style = CodeTheme.typography.textSmall, - color = White50, - ) - Icon( - // Same glyph as "Full Screen ⌄", flipped — the affordance reverses. - modifier = Modifier.size(16.dp).rotate(180f), - painter = painterResource(R.drawable.ic_chevron_down_medium), - contentDescription = null, - tint = White50, - ) - } - } - } -} diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt index 27fde4cc40..0e07e3324e 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt @@ -23,13 +23,6 @@ interface BillOperations { val billState: StateFlow fun showBill(bill: Scannable.Payable) fun dismissBill(action: BillDeterminationResult) - - /** - * Presents the viewer's *own* tip card full screen in the bill container (e.g. from the You tab), - * for display only — no Send-a-Tip modal, no submission. Dismissal reuses the overlay's - * drag-to-dismiss. A no-op if a bill is already showing (re-entrancy guard). - */ - fun presentOwnTipCard(card: Scannable.TipCard) } interface CodeScanOperations { diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt index 15dd13ed12..4fb65d8866 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt @@ -127,15 +127,6 @@ class BillPresentationDelegate @Inject constructor( stateHolder.update { it.copy(billResult = Grabbed) } } - /** - * Presents the viewer's own tip card for display. Flags it [Scannable.TipCard.isSelf] so the - * overlay attaches the no-op decorator (no Send-a-Tip modal / add-money prompt). Reuses - * [presentTipCard]'s single-slot guard as the double-present guard. - */ - override fun presentOwnTipCard(card: Scannable.TipCard) { - presentTipCard(card.copy(isSelf = true)) - } - override fun dismissBill(action: BillDeterminationResult) { scope.launch { stateHolder.update { it.copy(billResult = action) } diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt index 21d78e3d90..074db18e81 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt @@ -54,8 +54,8 @@ class TipCardDelegate @Inject constructor( private val inFlight = MutableStateFlow>(emptySet()) override fun resolveTipCard(user: ID) { - // You can't tip yourself: ignore a scanned or deeplinked own tip card. Own-card display - // goes through BillOperations.presentOwnTipCard (the You tab), not this resolve path. + // You can't tip yourself: ignore a scanned or deeplinked own tip card. Own-card display is + // its own screen (AppRoute.Menu.TipCard, from the You tab), not this resolve path. // Mirrors iOS TipFlow.begin's `guard userID != session.userID`. if (user == tippingCoordinator.currentUserId) return if (!inFlight.add(user)) return 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 cc1dc8337e..1f7faebfc6 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 @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer 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 @@ -22,6 +23,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.getcode.theme.CodeTheme import com.getcode.theme.White20 +import com.getcode.ui.theme.CodeToggleSwitch import androidx.compose.foundation.clickable private val ListItemIconSize = 24.dp @@ -123,4 +125,22 @@ object ListItemDefaults { tint = White20, ) } + + /** + * A trailing switch that keeps the row the same height as every other one. It is + * presentation-only — the row's own `onClick` performs the toggle — and that is what buys the + * height: Material only applies its 48dp minimum touch target when `onCheckedChange` is + * non-null, which otherwise makes a toggle row ~24dp taller than its icon-and-chevron + * neighbours. Pinning the node to the icon size lets the 32dp track paint centred over it, + * inside the row's own padding. + */ + @Composable + fun Toggle(checked: Boolean, enabled: Boolean = true) { + CodeToggleSwitch( + modifier = Modifier.height(ListItemIconSize), + checked = checked, + enabled = enabled, + onCheckedChange = null, + ) + } } From b9bd62b0271fee0b22777e1819246059d760b1ac Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 22:58:09 -0400 Subject: [PATCH 3/8] feat(you): expand the tip card in place instead of pushing a screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full Screen now morphs the card that's already on the You tab rather than navigating to a dedicated TipCardScreen. The card keeps its slot in the list while an inner layer translates it to the display centre and grows it to the 302dp expanded width (node 9277:121410); the rest of the page fades and slides out under it, the tab bar hides via a counted TabBarVisibilityController, and Close/back collapse it. Spring matches iOS's .spring(response: 0.45, dampingFraction: 0.85) — dampingRatio 0.85, stiffness 195. Also: - Copy link now flips to a filled checkmark.circle.fill-style glyph for 1.5s before fading back to the copy icon, matching iOS. - Version footer sits 44dp below the last row to match iOS's 69dp gap. - Switch Accounts moves into Advanced Features next to Beta Features, and the Advanced view model gained the staff/feature-flag filtering it was missing. - MenuList takes an itemModifier that wraps the row *and* its sibling divider, so fading the list out no longer leaves divider hairlines behind. - Clamp the animated tab-bar inset: the underdamped spring undershoots below zero, PaddingValues throws on a negative, and the exception was killing the Recomposer and freezing the whole UI mid-expansion. Removes TipCardScreen, its content and view model, and the session-presented tip card path. --- .../app/internal/ui/AppNavigationBar.kt | 5 +- .../app/internal/ui/navigation/AppContent.kt | 31 +- .../ui/navigation/AppScreenContent.kt | 2 - .../kotlin/com/flipcash/app/core/AppRoute.kt | 4 - .../app/core/navigation/TabBarVisibility.kt | 46 ++ .../app/advanced/AdvancedFeaturesScreen.kt | 7 + .../internal/AdvancedFeatureMenuItems.kt | 17 + .../AdvancedFeaturesScreenViewModel.kt | 65 ++- .../com/flipcash/app/menu/MenuScreen.kt | 8 - .../com/flipcash/app/menu/TipCardScreen.kt | 15 - .../flipcash/app/menu/internal/MenuItems.kt | 12 - .../app/menu/internal/MenuScreenContent.kt | 430 ++++++++++++++---- .../app/menu/internal/MenuScreenViewModel.kt | 28 +- .../internal/tipcard/TipCardScreenContent.kt | 111 ----- .../tipcard/TipCardScreenViewModel.kt | 32 -- .../kotlin/com/flipcash/app/menu/MenuList.kt | 13 +- .../internal/delegates/TipCardDelegate.kt | 4 +- .../src/main/res/drawable/ic_check_circle.xml | 13 + 18 files changed, 499 insertions(+), 344 deletions(-) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/TabBarVisibility.kt delete mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/TipCardScreen.kt delete mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/tipcard/TipCardScreenContent.kt delete mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/tipcard/TipCardScreenViewModel.kt create mode 100644 ui/resources/src/main/res/drawable/ic_check_circle.xml diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt index c0b36d0c91..9bfeb43b57 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt @@ -49,6 +49,9 @@ internal fun AppNavigationBar( navigator: CodeNavigator, modifier: Modifier = Modifier, hazeState: HazeState? = null, + // A tab home taking over the whole screen without leaving its route (the You tab's tip card + // expanding in place). See TabBarVisibilityController. + forceHidden: Boolean = false, ) { // Selection follows the base of the backstack (the tab "home"), so it stays correct while a // sheet/modal sits on top and is right on launch. The top route only gates visibility. @@ -79,7 +82,7 @@ internal fun AppNavigationBar( contentAlignment = Alignment.BottomCenter, ) { AnimatedVisibility( - visible = topTab != null && bottomBarMessages.isEmpty() && !billUp, + visible = topTab != null && bottomBarMessages.isEmpty() && !billUp && !forceHidden, enter = slideInVertically { it } + fadeIn(), exit = slideOutVertically { it } + fadeOut(), ) { diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index e3a743d601..256d269008 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt @@ -6,9 +6,7 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -30,6 +28,8 @@ import com.flipcash.app.cardexpand.CardExpansionController import com.flipcash.app.cardexpand.LocalCardExpansion import com.flipcash.app.core.AppRoute import com.flipcash.app.core.navigation.DeeplinkAction +import com.flipcash.app.core.navigation.LocalTabBarVisibility +import com.flipcash.app.core.navigation.TabBarVisibilityController import com.flipcash.app.core.navigation.asNavBarTab import com.flipcash.app.core.ui.transitions.CardExpandTransition import com.flipcash.app.internal.ui.AppNavigationBar @@ -65,8 +65,6 @@ private fun isTokenInfoKey(key: Any?): Boolean { private fun isGiveKey(key: Any?): Boolean = key?.toString()?.startsWith("Give(") == true -private fun isSelfTipCardKey(key: Any?): Boolean = key is AppRoute.Menu.TipCard - @Composable internal fun AppContent( codeNavigator: CodeNavigator, @@ -182,12 +180,19 @@ internal fun NewAppContent( val hazeState = rememberHazeState() val tabBarHeight = remember { mutableStateOf(0.dp) } + // Lets a tab home hide the bar without leaving its route — the You tab's tip card expands to + // full screen in place, so there's no route change for the visibility rule below to notice. + val tabBarVisibility = remember { TabBarVisibilityController() } + // Card-expand (iOS #587): the wallet requests an expansion (via LocalCardExpansion); the detail is // drawn by CardExpandHost inside the wallet entry, driven by one progress scalar, so the deck stays // composed and reorganises behind it. See CardExpansionController / CurrencyInfoExpansion. // [cardExpansion] is owned by App so a `/token` deeplink — which is handled there, outside this // shell — can open a token as its expanded card instead of pushing a screen. - CompositionLocalProvider(LocalCardExpansion provides cardExpansion) { + CompositionLocalProvider( + LocalCardExpansion provides cardExpansion, + LocalTabBarVisibility provides tabBarVisibility, + ) { Box(modifier = Modifier.fillMaxSize()) { // Mark the nav content as the haze source so the frosted bar blurs whatever scrolls beneath it. Box(modifier = Modifier.hazeSource(hazeState)) { @@ -232,13 +237,6 @@ internal fun NewAppContent( // CardExpandTransition + TokenCardStack. isTokenInfoKey(targetState.key) -> CardExpandTransition.openEnter togetherWith CardExpandTransition.openExit - // The You tab's own tip card, full screen: the card is already on screen, - // so nothing slides in over it — the page (and, because this isn't a tab - // route, the tab bar) drops away downward and leaves it, matching the - // "Full Screen ⌄" chevron that opened it. - isSelfTipCardKey(targetState.key) -> - fadeIn(tween(300)) togetherWith - (slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(300))) landsOnTab -> fadeIn(tween(300)) togetherWith fadeOut(tween(300)) else -> @@ -259,11 +257,6 @@ internal fun NewAppContent( // Swapping in a single frame keeps the scrim continuously up. isGiveKey(initialState.key) -> EnterTransition.None togetherWith ExitTransition.None - // Reverse of the open: the page rides back up over the card. See the - // matching branch in transitionSpec. - isSelfTipCardKey(initialState.key) -> - (slideInVertically(initialOffsetY = { it }) + fadeIn(tween(300))) togetherWith - fadeOut(tween(300)) else -> slideInHorizontally(initialOffsetX = { -it }) togetherWith slideOutHorizontally(targetOffsetX = { it }) @@ -277,9 +270,6 @@ internal fun NewAppContent( isTokenInfoKey(initialState.key) -> CardExpandTransition.predictiveCloseEnter togetherWith CardExpandTransition.predictiveCloseExit - isSelfTipCardKey(initialState.key) -> - (slideInVertically(initialOffsetY = { it }) + fadeIn(tween(300))) togetherWith - fadeOut(tween(300)) else -> slideInHorizontally(initialOffsetX = { -it }) togetherWith slideOutHorizontally(targetOffsetX = { it }) @@ -301,6 +291,7 @@ internal fun NewAppContent( AppNavigationBar( navigator = codeNavigator, hazeState = hazeState, + forceHidden = tabBarVisibility.isHidden, modifier = Modifier .align(Alignment.BottomCenter) .measured { if (it.height > tabBarHeight.value) tabBarHeight.value = it.height } 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 e63ea9ab3d..80cbc8f3c9 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 @@ -41,7 +41,6 @@ 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.menu.TipCardScreen import com.flipcash.app.myaccount.BlocklistScreen import com.flipcash.app.myaccount.UserProfileScreen import com.flipcash.app.myaccount.MyAccountScreen @@ -149,7 +148,6 @@ fun appEntryProvider( annotatedEntry { key -> LabsScreen(onboarding = key.onboarding) } annotatedEntry { NavBarSettingsScreen() } annotatedEntry { UserProfileScreen() } - annotatedEntry { TipCardScreen() } annotatedEntry { MyAccountScreen() } annotatedEntry { BlocklistScreen() } annotatedEntry { BackupKeyScreen() } 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 5be4d04aa8..729de1e98a 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 @@ -307,10 +307,6 @@ sealed interface AppRoute : NavKey, Parcelable { data object DeviceLogs : Menu @Serializable data object UserProfile : Menu - - /** The viewer's own tip card, full screen (node 9277:121410). */ - @Serializable - data object TipCard : Menu @Serializable data class Lab(val onboarding: Boolean = false) : Menu @Serializable diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/TabBarVisibility.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/TabBarVisibility.kt new file mode 100644 index 0000000000..6d2c93ba46 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/TabBarVisibility.kt @@ -0,0 +1,46 @@ +package com.flipcash.app.core.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * Lets a tab home take the whole screen without leaving its route. + * + * The v2 tab bar is root chrome hoisted above the nav content, so it normally hides only when the + * top route stops being a tab home. A screen that expands something to full screen *in place* — the + * You tab's tip card — never changes route, so it has to say so. + * + * Requests are counted rather than latched to a boolean so overlapping callers can't uncover the + * bar from under one another. + */ +@Stable +class TabBarVisibilityController { + private var requests by mutableStateOf(0) + + val isHidden: Boolean get() = requests > 0 + + fun hide() { + requests++ + } + + fun release() { + requests = (requests - 1).coerceAtLeast(0) + } +} + +val LocalTabBarVisibility = staticCompositionLocalOf { TabBarVisibilityController() } + +/** Hides the tab bar for as long as [hidden] holds and this composable stays in the tree. */ +@Composable +fun HideTabBar(hidden: Boolean) { + val controller = LocalTabBarVisibility.current + DisposableEffect(controller, hidden) { + if (hidden) controller.hide() + onDispose { if (hidden) controller.release() } + } +} diff --git a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/AdvancedFeaturesScreen.kt b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/AdvancedFeaturesScreen.kt index 3c03919518..5a8584e0b7 100644 --- a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/AdvancedFeaturesScreen.kt +++ b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/AdvancedFeaturesScreen.kt @@ -65,6 +65,13 @@ fun AdvancedFeaturesScreen() { .launchIn(this) } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { navigator.hide() } + .launchIn(this) + } + LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() diff --git a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt index 296be3fa5f..7d83433885 100644 --- a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt +++ b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt @@ -11,7 +11,9 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.flipcash.app.core.AppRoute +import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.menu.FullMenuItem +import com.flipcash.app.menu.StaffMenuItem import com.flipcash.core.R import com.getcode.util.resources.icons.Delete @@ -72,3 +74,18 @@ internal data object DeleteAccount : FullMenuItem() { + override val icon: Painter + @Composable get() = painterResource(R.drawable.ic_menu_switchaccounts) + override val name: String + @Composable get() = stringResource(R.string.title_switchAccounts) + override val action: AdvancedFeaturesScreenViewModel.Event = + AdvancedFeaturesScreenViewModel.Event.OnSwitchAccountsClicked + override val featureFlag: FeatureFlag<*> = FeatureFlag.CredentialManager +} diff --git a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt index bccd2f26ba..0be3e9d3b8 100644 --- a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt +++ b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeaturesScreenViewModel.kt @@ -3,10 +3,13 @@ package com.flipcash.app.advanced.internal import androidx.lifecycle.viewModelScope import com.flipcash.app.auth.AuthManager import com.flipcash.app.core.AppRoute -import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.core.extensions.onResult +import com.flipcash.app.featureflags.BetaFeature import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.menu.MenuItem +import com.flipcash.app.menu.StaffMenuItem import com.flipcash.app.userflags.UserFlagsCoordinator +import com.getcode.opencode.managers.MnemonicManager import com.flipcash.core.R import com.flipcash.libs.coroutines.DispatcherProvider import com.getcode.manager.BottomBarAction @@ -28,6 +31,7 @@ private val FullMenuList = buildList { add(BetaFlags) add(DeviceLogs) // add(BillCustomizer) + add(SwitchAccount) add(LogOut) add(DeleteAccount) } @@ -38,6 +42,7 @@ internal class AdvancedFeaturesScreenViewModel @Inject constructor( userFlags: UserFlagsCoordinator, resources: ResourceHelper, authManager: AuthManager, + mnemonicManager: MnemonicManager, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -46,12 +51,21 @@ internal class AdvancedFeaturesScreenViewModel @Inject constructor( ) { data class State( val isBetaEnabled: Boolean = false, - val items: List> = FullMenuList + val flags: List = emptyList(), + // Default hides staff-only AND flag-gated items until the real state loads, so a beta-gated + // row never flashes before its flag resolves. + val items: List> = + FullMenuList.filterNot { it is StaffMenuItem || it.featureFlag != null }, ) sealed interface Event { - data class OnBetaFeaturesUnlocked(val unlocked: Boolean) : Event + data class OnBetaFeaturesUnlocked( + val unlocked: Boolean, + val flags: List = emptyList(), + ) : Event data class OpenScreen(val screen: AppRoute) : Event + data object OnSwitchAccountsClicked : Event + data class OnSwitchAccountTo(val entropy: String) : Event data object OpenBillPlayground : Event data object OnAccessKeyClicked : Event data object OnViewAccessKey : Event @@ -64,13 +78,30 @@ internal class AdvancedFeaturesScreenViewModel @Inject constructor( init { combine( featureFlagController.observeOverride(), - userFlags.resolvedFlags.map { it.isStaff.effectiveValue } - ) { override, isStaff -> - override || isStaff - }.map { - dispatchEvent(Event.OnBetaFeaturesUnlocked(it)) + userFlags.resolvedFlags.map { it.isStaff.effectiveValue }, + featureFlagController.observe(), + ) { override, isStaff, flags -> + dispatchEvent(Event.OnBetaFeaturesUnlocked(override || isStaff, flags)) }.launchIn(viewModelScope) + // Hands off to Google's Password Manager to pick another access key, then re-logs in as it. + eventFlow + .filterIsInstance() + .map { + authManager.selectAccount() + .fold( + onSuccess = { + authManager.logoutAndSwitchAccount( + mnemonicManager.getEncodedBase64(it) + ) + }, + onFailure = { Result.failure(it) } + ) + }.onResult( + onError = { }, + onSuccess = { dispatchEvent(Event.OnSwitchAccountTo(it)) } + ).launchIn(viewModelScope) + eventFlow .filterIsInstance() .onEach { @@ -140,15 +171,33 @@ internal class AdvancedFeaturesScreenViewModel @Inject constructor( } internal companion object { + /** + * Staff-only rows need beta access (staff, or the version-footer override); flag-gated rows + * additionally need their flag switched on server-side. + */ + private fun buildItemList( + unlocked: Boolean, + flags: List, + ): List> = FullMenuList + .filter { it !is StaffMenuItem || unlocked } + .filter { item -> + val flag = item.featureFlag ?: return@filter true + flags.find { it.flag.key == flag.key }?.enabled == true + } + private val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { is Event.OnBetaFeaturesUnlocked -> { state -> state.copy( isBetaEnabled = event.unlocked, + flags = event.flags, + items = buildItemList(unlocked = event.unlocked, flags = event.flags), ) } is Event.OpenScreen, + Event.OnSwitchAccountsClicked, + is Event.OnSwitchAccountTo, Event.OpenBillPlayground, Event.OnAccessKeyClicked, Event.OnViewAccessKey, diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/MenuScreen.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/MenuScreen.kt index ab6d8e5077..71cc14bd27 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/MenuScreen.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/MenuScreen.kt @@ -3,7 +3,6 @@ package com.flipcash.app.menu import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.hilt.navigation.compose.hiltViewModel -import com.flipcash.app.core.AppRoute import com.flipcash.app.menu.internal.MenuScreenContent import com.flipcash.app.menu.internal.MenuScreenViewModel import com.getcode.navigation.core.LocalCodeNavigator @@ -26,11 +25,4 @@ fun MenuScreen() { .onEach { navigator.push(it) } .launchIn(this) } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { navigator.hide() } - .launchIn(this) - } } diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/TipCardScreen.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/TipCardScreen.kt deleted file mode 100644 index 17d4361613..0000000000 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/TipCardScreen.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.flipcash.app.menu - -import androidx.compose.runtime.Composable -import androidx.hilt.navigation.compose.hiltViewModel -import com.flipcash.app.menu.internal.tipcard.TipCardScreenContent -import com.flipcash.app.menu.internal.tipcard.TipCardScreenViewModel -import com.getcode.navigation.core.LocalCodeNavigator - -@Composable -fun TipCardScreen() { - val viewModel = hiltViewModel() - val navigator = LocalCodeNavigator.current - - TipCardScreenContent(viewModel = viewModel, onClose = { navigator.pop() }) -} diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt index b24ad75e2d..df1df18880 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuItems.kt @@ -6,9 +6,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.TokenPurpose -import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.menu.FullMenuItem -import com.flipcash.app.menu.StaffMenuItem import com.flipcash.features.menu.R internal data object MyAccount : FullMenuItem() { @@ -30,13 +28,3 @@ internal data object AdvancedFeatures : FullMenuItem( AppRoute.Menu.AdvancedFeatures ) } - -internal data object SwitchAccount : StaffMenuItem() { - override val icon: Painter - @Composable get() = painterResource(R.drawable.ic_menu_switchaccounts) - override val name: String - @Composable get() = stringResource(R.string.title_switchAccounts) - override val action: MenuScreenViewModel.Event = MenuScreenViewModel.Event.OnSwitchAccountsClicked - override val featureFlag: FeatureFlag<*> = FeatureFlag.CredentialManager -} - 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 38a1efda26..5ade04023f 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 @@ -1,9 +1,21 @@ package com.flipcash.app.menu.internal +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues @@ -20,6 +32,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsIgnoringVisibility +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -27,14 +40,25 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.rotate import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -43,6 +67,7 @@ import com.flipcash.app.bills.components.cards.LocalTipCardBaseAlpha import com.flipcash.app.bills.components.cards.LocalTipCardColor import com.flipcash.app.core.AppRoute import com.flipcash.app.core.bill.Scannable +import com.flipcash.app.core.navigation.HideTabBar import com.flipcash.app.core.navigation.LocalTabBarPadding import com.flipcash.app.core.ui.TileButton import com.flipcash.app.featureflags.FeatureFlag @@ -61,6 +86,7 @@ import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.core.noRippleClickable import com.getcode.ui.theme.CodeScaffold +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -77,6 +103,20 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { // froze that default, so a v1 build rendered the v2 "You" screen. val isNewUi by features.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() + val listState = rememberLazyListState() + // Full screen is a state of *this* screen, not a destination: the card grows into the middle of + // the display and everything else — rows, footer, tab bar — animates out from under it + // (node 9277:121410). Pushing a route would cross-fade a second copy of the card in instead. + var cardExpanded by remember { mutableStateOf(false) } + val canExpand = isNewUi && state.tipCard != null + + LaunchedEffect(canExpand) { + // Losing the card (a v1 build, or sign-out) must not strand the page expanded. + if (!canExpand) cardExpanded = false + } + HideTabBar(hidden = cardExpanded) + BackHandler(enabled = cardExpanded) { cardExpanded = false } + LaunchedEffect(Unit) { viewModel.eventFlow .filterIsInstance() @@ -110,58 +150,138 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { } } ) { padding -> - MenuList( + BoxWithConstraints( modifier = Modifier .fillMaxSize() .padding(padding), - items = state.items, - showChevrons = isNewUi, - header = { - if (isNewUi) { - YouHeader( - card = state.tipCard, - link = state.tipLink, - onCopyLink = { viewModel.dispatchEvent(Event.CopyTipLink) }, - onShare = { viewModel.dispatchEvent(Event.ShareTipCard) }, - onDownload = { viewModel.dispatchEvent(Event.DownloadTipCard) }, - ) - } else { - MoneyTiles(viewModel, navigator) - } - }, - footer = { - if (isNewUi) { - // Scrolls with the list, so it needs its own breathing room off the last row's - // divider. No navigationBarsPadding here — the reserved tab-bar inset below - // already clears the system bar (the bar measures itself with that padding in). - VersionFooter( - viewModel = viewModel, - state = state, - modifier = Modifier.padding( - top = CodeTheme.dimens.grid.x6, - bottom = CodeTheme.dimens.grid.x3, - ), - ) - } - }, + ) { + // No app bar in v2, so the page owns its own status-bar clearance; the design puts the + // card 74dp below it (node 9278:7301). + val restingTop = WindowInsets.statusBars.asPaddingValues() + .calculateTopPadding() + CardTopSpacing + // The design's width, narrowed only if the display can't hold it inside the page's + // margins — same rule iOS applies. + val expandedCardWidth = minOf( + FullScreenCardWidth, + maxWidth - PageHorizontalInset * 2, + ) + val cardWidth by animateDpAsState( + targetValue = if (cardExpanded) expandedCardWidth else YouCardWidth, + animationSpec = expansionSpring(Dp.VisibilityThreshold), + label = "tipCardWidth", + ) // v2's tab bar is a hoisted overlay drawn ABOVE this content, so reserve its height as // bottom content padding — the list then scrolls clear of the bar instead of running // under it (the version footer was landing behind it). Per-entry via LocalTabBarPadding, - // which is only non-zero for tab homes. v1 has no such bar. - contentPadding = PaddingValues( - // No app bar in v2, so the page owns its own status-bar clearance; the design puts - // the card 74dp below it (node 9278:7301). - top = if (isNewUi) { - WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + CardTopSpacing - } else { - CodeTheme.dimens.grid.x3 + // which is only non-zero for tab homes. v1 has no such bar; expanding gives the bar back + // its space because HideTabBar has taken the bar away. + val tabBarInset = LocalTabBarPadding.current.calculateBottomPadding() + val bottomInset by animateDpAsState( + targetValue = if (cardExpanded) 0.dp else tabBarInset, + animationSpec = expansionSpring(Dp.VisibilityThreshold), + label = "tabBarInset", + ) + // How far into the expansion we are: everything but the card fades out on it and slides + // down out of the way, rather than being removed. Keeping the rows in the layout means + // nothing reflows on the way back (iOS does the same with opacity + offset). + val expansion by animateFloatAsState( + targetValue = if (cardExpanded) 1f else 0f, + animationSpec = expansionSpring(), + label = "expansion", + ) + val slideAway = Modifier.graphicsLayer { + // Same overshoot: keep the fade inside a legal alpha range. + alpha = (1f - expansion).coerceIn(0f, 1f) + translationY = ContentSlideDistance.toPx() * expansion + } + + // The card doesn't hand off to a second copy of itself: the one in the list keeps its + // slot and is drawn travelling out of it, the way iOS offsets the card from its own + // measured frame. Measuring the slot (which never carries the offset) rather than the + // card keeps the measurement out of its own feedback loop, and because the slot grows + // with the card, the card is exactly centred by the time the spring settles. + var cardSlotCenterY by remember { mutableFloatStateOf(0f) } + val displayCenterY = LocalWindowInfo.current.containerSize.height / 2f + val cardShift = when { + cardSlotCenterY <= 0f -> 0f + else -> (displayCenterY - cardSlotCenterY) * expansion + } + + MenuList( + modifier = Modifier.fillMaxSize(), + state = listState, + items = state.items, + showChevrons = isNewUi, + userScrollEnabled = !cardExpanded, + itemModifier = if (isNewUi) slideAway else Modifier, + header = { + if (isNewUi) { + YouHeader( + card = state.tipCard, + link = state.tipLink, + enabled = !cardExpanded, + expansion = expansion, + slideAway = slideAway, + cardWidth = cardWidth, + cardShift = cardShift, + onCardSlotPositioned = { cardSlotCenterY = it }, + onToggleFullScreen = { cardExpanded = !cardExpanded }, + onCopyLink = { viewModel.dispatchEvent(Event.CopyTipLink) }, + onShare = { viewModel.dispatchEvent(Event.ShareTipCard) }, + onDownload = { viewModel.dispatchEvent(Event.DownloadTipCard) }, + ) + } else { + MoneyTiles(viewModel, navigator) + } }, - bottom = LocalTabBarPadding.current.calculateBottomPadding(), - ), - onItemClick = { - viewModel.dispatchEvent(it.action) + footer = { + if (isNewUi) { + // Scrolls with the list, so it needs its own breathing room off the last + // row's divider. No navigationBarsPadding here — the reserved tab-bar inset + // below already clears the system bar (the bar measures itself with that + // padding in). + VersionFooter( + viewModel = viewModel, + state = state, + enabled = !cardExpanded, + modifier = slideAway.padding( + top = VersionFooterTopSpacing, + bottom = CodeTheme.dimens.grid.x3, + ), + ) + } + }, + contentPadding = PaddingValues( + top = if (isNewUi) restingTop else CodeTheme.dimens.grid.x3, + // Clamped: the spring is underdamped, so it undershoots past the target on the + // way to 0, and PaddingValues throws on a negative — taking the Recomposer, and + // with it the whole UI, down with it. + bottom = bottomInset.coerceAtLeast(0.dp), + ), + onItemClick = { + // The faded-out rows are still laid out under the expanded card; don't let them + // take a tap meant for the card. + if (!cardExpanded) viewModel.dispatchEvent(it.action) + } + ) + + // Close sits at the foot of the display rather than under the card (node 9277:121410). + AnimatedVisibility( + visible = cardExpanded, + modifier = Modifier.align(Alignment.BottomCenter), + enter = fadeIn(expansionSpring()), + exit = fadeOut(expansionSpring()), + ) { + FullScreenToggle( + label = stringResource(R.string.action_closeFullScreen), + chevronRotation = 180f, + modifier = Modifier + .navigationBarsPadding() + .padding(bottom = CloseBottomSpacing) + .noRippleClickable { cardExpanded = false }, + ) } - ) + } } } @@ -171,22 +291,62 @@ private val CardTopSpacing = 74.dp /** The at-rest card width on the You tab (node 9278:7301: 241.636). */ private val YouCardWidth = 242.dp +/** The expanded card's width (node 9277:121410: 302.21 on a 402 frame); iOS pins the same 302. */ +private val FullScreenCardWidth = 302.dp + +/** The page's horizontal inset, and so the expanded card's minimum margin. */ +private val PageHorizontalInset = 20.dp + +/** Gap between the Close row and the system nav bar (node 9277:121410). */ +private val CloseBottomSpacing = 8.dp + +/** + * Clearance between the last settings row's divider and the version footer. iOS spends 32 above the + * footer plus 12 of the footer's own vertical padding on top of the row's 25 inset; the Android row + * already pays that same 25, so the difference lands here. + */ +private val VersionFooterTopSpacing = 44.dp + +/** How far the page's content slides down as it fades out under the expanding card. */ +private val ContentSlideDistance = 60.dp + +/** + * The whole expansion — card size, card position, the content sliding away, the Close row — runs on + * one spring, as iOS does: `.spring(response: 0.45, dampingFraction: 0.85)`. SwiftUI's `response` is + * the undamped period, so the equivalent Compose stiffness is `(2 * PI / 0.45) ^ 2`. + */ +private fun expansionSpring(visibilityThreshold: T? = null) = spring( + dampingRatio = 0.85f, + stiffness = 195f, + visibilityThreshold = visibilityThreshold, +) + /** * The "You" tab header (node 9276:4634): the viewer's own tip card with a "Full Screen" affordance, * the copyable tip link, and the Share / Download tiles. + * + * The caller drives the full-screen state: it sizes the card ([cardWidth]) and draws it out of its + * slot towards the middle of the display ([cardShift], off the slot position reported by + * [onCardSlotPositioned]). It also hands down [slideAway] — the fade-and-slide every non-card + * element shares — plus [expansion] for the caption, which iOS fades in place rather than sliding. */ @OptIn(ExperimentalLayoutApi::class) @Composable private fun YouHeader( card: Scannable.TipCard?, link: String?, + enabled: Boolean, + expansion: Float, + slideAway: Modifier, + cardWidth: Dp, + cardShift: Float, + onCardSlotPositioned: (Float) -> Unit, + onToggleFullScreen: () -> Unit, onCopyLink: () -> Unit, onShare: () -> Unit, onDownload: () -> Unit, ) { if (card == null) return - val navigator = LocalCodeNavigator.current - val openFullScreen = { navigator.push(AppRoute.Menu.TipCard) } Column( modifier = Modifier.fillMaxWidth(), @@ -204,80 +364,132 @@ private fun YouHeader( // list's content padding already owns that clearance, so consume the inset // rather than paying it twice. .consumeWindowInsets(WindowInsets.statusBarsIgnoringVisibility) - .noRippleClickable { openFullScreen() }, + .onGloballyPositioned { onCardSlotPositioned(it.boundsInRoot().center.y) }, contentAlignment = Alignment.Center, ) { - ScannableRenderer(scannable = card, tipCardWidth = YouCardWidth) + Box( + modifier = Modifier + .graphicsLayer { translationY = cardShift } + .noRippleClickable { onToggleFullScreen() }, + ) { + ScannableRenderer(scannable = card, tipCardWidth = cardWidth) + } } } Spacer(Modifier.height(CodeTheme.dimens.grid.x6)) - Row( - modifier = Modifier.noRippleClickable { openFullScreen() }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp), - ) { - Text( - text = stringResource(R.string.action_viewFullScreen), - style = CodeTheme.typography.textSmall, - color = White50, - ) - Icon( - modifier = Modifier.size(16.dp), - painter = painterResource(R.drawable.ic_chevron_down_medium), - contentDescription = null, - tint = White50, - ) - } - - Spacer(Modifier.height(64.dp)) + // The caption belongs to the card, so it fades where it stands instead of sliding off with + // the rest of the page. + FullScreenToggle( + label = stringResource(R.string.action_viewFullScreen), + chevronRotation = 0f, + modifier = Modifier + .graphicsLayer { alpha = (1f - expansion).coerceIn(0f, 1f) } + .noRippleClickable { onToggleFullScreen() }, + ) + // Everything under the card gets out of the way so the card can own the display. Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.grid.x5), - verticalArrangement = Arrangement.spacedBy(11.dp), + modifier = slideAway.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, ) { - if (link != null) { - TipLinkRow(link = link, onCopy = onCopyLink) - } + Spacer(Modifier.height(64.dp)) - Row( + Column( modifier = Modifier .fillMaxWidth() - .height(88.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), + .padding(horizontal = CodeTheme.dimens.grid.x5), + verticalArrangement = Arrangement.spacedBy(11.dp), ) { - ShareTile( - modifier = Modifier.weight(1f), - icon = R.drawable.ic_share_os, - label = stringResource(R.string.action_share), - onClick = onShare, - ) - ShareTile( - modifier = Modifier.weight(1f), - icon = R.drawable.ic_file_download, - label = stringResource(R.string.action_download), - onClick = onDownload, - ) + if (link != null) { + TipLinkRow(link = link, enabled = enabled, onCopy = onCopyLink) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(88.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + ShareTile( + modifier = Modifier.weight(1f), + icon = R.drawable.ic_share_os, + label = stringResource(R.string.action_share), + enabled = enabled, + onClick = onShare, + ) + ShareTile( + modifier = Modifier.weight(1f), + icon = R.drawable.ic_file_download, + label = stringResource(R.string.action_download), + enabled = enabled, + onClick = onDownload, + ) + } } + + Spacer(Modifier.height(19.dp)) } + } +} - Spacer(Modifier.height(19.dp)) +/** + * The label + chevron that toggles the card's full-screen state — "Full Screen" pointing down under + * the resting card (node 9276:4634), "Close" pointing up at the foot of the expanded one + * (node 9277:121410). One glyph, flipped, so the two read as the same control. + */ +@Composable +private fun FullScreenToggle( + label: String, + chevronRotation: Float, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Text( + text = label, + style = CodeTheme.typography.textSmall, + color = White50, + ) + Icon( + modifier = Modifier + .size(16.dp) + .rotate(chevronRotation), + painter = painterResource(R.drawable.ic_chevron_down_medium), + contentDescription = null, + tint = White50, + ) } } /** The tip link, tap-to-copy (node 9276:4748). Shown short — the full URL goes to the clipboard. */ @Composable -private fun TipLinkRow(link: String, onCopy: () -> Unit) { +private fun TipLinkRow(link: String, enabled: Boolean, onCopy: () -> Unit) { + // 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 .fillMaxWidth() .height(40.dp) .clip(TileShape) .background(White05) - .clickable { onCopy() } + .clickable(enabled = enabled) { + onCopy() + copyToken++ + } .padding(horizontal = CodeTheme.dimens.grid.x3), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, @@ -301,15 +513,31 @@ private fun TipLinkRow(link: String, onCopy: () -> Unit) { overflow = TextOverflow.Ellipsis, ) } - Icon( - modifier = Modifier.size(20.dp), - painter = painterResource(R.drawable.ic_copy), - contentDescription = null, - tint = White, - ) + // Confirms the copy landed, then hands the row back to the copy glyph (mirrors iOS + // TipCardLinkRow) — the clipboard gives no feedback of its own. + Crossfade( + targetState = copied, + animationSpec = tween(CopyIconFadeMillis, easing = EaseInOut), + label = "copyConfirmation", + ) { showCheck -> + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource( + if (showCheck) R.drawable.ic_check_circle else R.drawable.ic_copy + ), + contentDescription = null, + tint = 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 + /** * One of the two square-ish actions under the link (node 9276:4756). The tile's height is fixed by * the parent row and the arrangement centres its contents, so it takes no vertical padding of its @@ -320,6 +548,7 @@ private fun ShareTile( modifier: Modifier = Modifier, icon: Int, label: String, + enabled: Boolean, onClick: () -> Unit, ) { Column( @@ -327,7 +556,7 @@ private fun ShareTile( .fillMaxSize() .clip(TileShape) .background(White05) - .clickable { onClick() }, + .clickable(enabled = enabled) { onClick() }, verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -399,13 +628,14 @@ private fun VersionFooter( viewModel: MenuScreenViewModel, state: MenuScreenViewModel.State, modifier: Modifier = Modifier, + enabled: Boolean = true, ) { Box(modifier = modifier.fillMaxWidth()) { Text( modifier = Modifier .fillMaxWidth() .align(Alignment.Center) - .noRippleClickable { + .noRippleClickable(enabled = enabled) { viewModel.dispatchEvent(Event.OnVersionInfoClicked) }, text = stringResource( diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index eb23b90651..f630d6c05d 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -4,7 +4,6 @@ import android.content.ClipboardManager import androidx.lifecycle.viewModelScope import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.FlipcashAnalyticsService -import com.flipcash.app.auth.AuthManager import com.flipcash.app.bills.share.TipCodePreviewCache import com.flipcash.app.core.AppRoute import com.flipcash.app.core.android.VersionInfo @@ -29,7 +28,6 @@ import com.flipcash.features.menu.R import com.flipcash.services.user.AuthState import com.flipcash.services.user.UserManager import com.flipcash.shared.tipping.TippingCoordinator -import com.getcode.opencode.managers.MnemonicManager import com.flipcash.libs.coroutines.DispatcherProvider import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper @@ -51,16 +49,13 @@ import javax.inject.Inject private val FullMenuList = buildList { add(MyAccount) add(AdvancedFeatures) - add(SwitchAccount) } @HiltViewModel internal class MenuScreenViewModel @Inject constructor( userManager: UserManager, userFlags: UserFlagsCoordinator, - authManager: AuthManager, versionInfo: VersionInfo, - mnemonicManager: MnemonicManager, featureFlags: FeatureFlagController, private val toastController: SystemToastController, dispatchers: DispatcherProvider, @@ -104,8 +99,6 @@ internal class MenuScreenViewModel @Inject constructor( data class OnStaffUserDetermined(val staff: Boolean) : Event data object PresentDepositOptions: Event data class OpenScreen(val screen: AppRoute) : Event - data object OnSwitchAccountsClicked : Event - data class OnSwitchAccountTo(val entropy: String): Event data class OnTipCardPopulated(val card: Scannable.TipCard, val link: String?) : Event data object ShareTipCard : Event data object CopyTipLink : Event @@ -174,23 +167,6 @@ internal class MenuScreenViewModel @Inject constructor( .onEach { dispatchEvent(Event.CheckForUpdate) } .launchIn(viewModelScope) - eventFlow - .filterIsInstance() - .map { - authManager.selectAccount() - .fold( - onSuccess = { - authManager.logoutAndSwitchAccount( - mnemonicManager.getEncodedBase64(it) - ) - }, - onFailure = { Result.failure(it) } - ) - }.onResult( - onError = { }, - onSuccess = { dispatchEvent(Event.OnSwitchAccountTo(it)) } - ).launchIn(viewModelScope) - eventFlow .filterIsInstance() .mapNotNull { @@ -352,13 +328,11 @@ internal class MenuScreenViewModel @Inject constructor( Event.PresentDepositOptions, Event.CheckForUpdate, - Event.OnSwitchAccountsClicked, Event.ShareTipCard, Event.CopyTipLink, Event.DownloadTipCard, is Event.ExportTipCard, - is Event.OpenScreen, - is Event.OnSwitchAccountTo -> { state -> state } + is Event.OpenScreen -> { state -> state } is Event.OnFeatureFlagsUpdated -> { state -> state.copy( diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/tipcard/TipCardScreenContent.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/tipcard/TipCardScreenContent.kt deleted file mode 100644 index fece5b950c..0000000000 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/tipcard/TipCardScreenContent.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.flipcash.app.menu.internal.tipcard - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsIgnoringVisibility -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.bills.ScannableRenderer -import com.flipcash.app.bills.components.cards.LocalTipCardBaseAlpha -import com.flipcash.app.bills.components.cards.LocalTipCardColor -import com.flipcash.features.menu.R -import com.getcode.theme.CodeTheme -import com.getcode.theme.White50 -import com.getcode.ui.core.noRippleClickable - -/** - * The viewer's own tip card, full screen (node 9277:121410). - * - * A route rather than a presented bill: the card is a static thing to hold up to a camera, so it - * gets the whole screen — the You tab's content and the tab bar leave with the push (the tab bar - * hides itself on any non-tab route) instead of the card competing with them under a scrim. - */ -@OptIn(ExperimentalLayoutApi::class) -@Composable -internal fun TipCardScreenContent(viewModel: TipCardScreenViewModel, onClose: () -> Unit) { - val card by viewModel.card.collectAsStateWithLifecycle() - - Box( - modifier = Modifier - .fillMaxSize() - .noRippleClickable { onClose() }, - ) { - if (card != null) { - // Static display, no camera behind it: opaque at the design's flattened colour rather - // than the translucent frosted fill. Figma flattens the card to rgb(16,16,17). - CompositionLocalProvider( - LocalTipCardColor provides Color(0xFF101011), - LocalTipCardBaseAlpha provides 1f, - ) { - ScannableRenderer( - // The card pads itself off the status bar for its in-scanner placement; here it - // is centred on the whole screen (the design centres it on the full frame), so - // consume that inset instead of paying it. - modifier = Modifier - .fillMaxSize() - .consumeWindowInsets(WindowInsets.statusBarsIgnoringVisibility), - scannable = requireNotNull(card), - tipCardWidth = fullScreenCardWidth(), - ) - } - } - - Row( - modifier = Modifier - .align(Alignment.BottomCenter) - .navigationBarsPadding() - .padding(bottom = CodeTheme.dimens.grid.x2) - .noRippleClickable { onClose() }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp), - ) { - Text( - text = stringResource(R.string.action_closeFullScreen), - style = CodeTheme.typography.textSmall, - color = White50, - ) - Icon( - // Same glyph as the "Full Screen ⌄" this was opened from, flipped — the affordance - // reverses. - modifier = Modifier - .size(16.dp) - .rotate(180f), - painter = painterResource(R.drawable.ic_chevron_down_medium), - contentDescription = null, - tint = White50, - ) - } - } -} - -/** - * Node 9277:121410 puts the card at 302.21 on a 402-wide frame, so it scales with the device rather - * than sitting at a fixed width. The cap only exists to stop a tablet blowing it up past the size a - * phone camera expects to read. - */ -@Composable -private fun fullScreenCardWidth(): Dp = - minOf(CodeTheme.dimens.screenWidth * FullScreenCardWidthFraction, FullScreenCardMaxWidth) - -private const val FullScreenCardWidthFraction = 302.21f / 402f -private val FullScreenCardMaxWidth: Dp = 360.dp diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/tipcard/TipCardScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/tipcard/TipCardScreenViewModel.kt deleted file mode 100644 index 1280e463e8..0000000000 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/tipcard/TipCardScreenViewModel.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.flipcash.app.menu.internal.tipcard - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.flipcash.app.core.bill.Scannable -import com.flipcash.shared.tipping.TippingCoordinator -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import javax.inject.Inject - -/** - * Resolves the viewer's own tip card for the full-screen presentation. The coordinator builds it - * from the cached profile, so this settles within a frame or two of the screen appearing — the - * nullable state is there for that gap, not for a real load. - */ -@HiltViewModel -internal class TipCardScreenViewModel @Inject constructor( - tippingCoordinator: TippingCoordinator, -) : ViewModel() { - - private val _card = MutableStateFlow(null) - val card: StateFlow = _card.asStateFlow() - - init { - viewModelScope.launch { - tippingCoordinator.resolveTipCard().onSuccess { _card.value = it } - } - } -} diff --git a/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuList.kt b/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuList.kt index 792f503b40..faf707edcc 100644 --- a/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuList.kt +++ b/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuList.kt @@ -1,5 +1,6 @@ package com.flipcash.app.menu +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.lazy.LazyColumn @@ -22,6 +23,8 @@ fun MenuList( header: @Composable (() -> Unit)? = null, footer: @Composable (() -> Unit)? = null, contentPadding: PaddingValues = PaddingValues(0.dp), + userScrollEnabled: Boolean = true, + itemModifier: Modifier = Modifier, onItemClick: (MenuItem) -> Unit ) { LazyColumn( @@ -32,13 +35,19 @@ fun MenuList( ).sheetResignmentBehavior(state), state = state, contentPadding = contentPadding, + userScrollEnabled = userScrollEnabled, ) { if (header != null) { item { header() } } items(items, key = { it.id }, contentType = { it }) { item -> - ListItem(modifier = Modifier.animateItem(), item = item, showChevron = showChevrons) { - onItemClick(item) + // [itemModifier] wraps the whole row rather than riding on ListItem's own modifier: + // ListItem emits its divider as a sibling of the row, so a modifier handed to the row + // alone would leave the divider behind (visibly, for callers fading the list out). + Column(modifier = Modifier.animateItem().then(itemModifier)) { + ListItem(item = item, showChevron = showChevrons) { + onItemClick(item) + } } } diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt index 074db18e81..19acacf47a 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt @@ -54,8 +54,8 @@ class TipCardDelegate @Inject constructor( private val inFlight = MutableStateFlow>(emptySet()) override fun resolveTipCard(user: ID) { - // You can't tip yourself: ignore a scanned or deeplinked own tip card. Own-card display is - // its own screen (AppRoute.Menu.TipCard, from the You tab), not this resolve path. + // You can't tip yourself: ignore a scanned or deeplinked own tip card. Your own card is + // shown by the You tab, which expands it in place — it never comes through this path. // Mirrors iOS TipFlow.begin's `guard userID != session.userID`. if (user == tippingCoordinator.currentUserId) return if (!inFlight.add(user)) return diff --git a/ui/resources/src/main/res/drawable/ic_check_circle.xml b/ui/resources/src/main/res/drawable/ic_check_circle.xml new file mode 100644 index 0000000000..c7ed337ea6 --- /dev/null +++ b/ui/resources/src/main/res/drawable/ic_check_circle.xml @@ -0,0 +1,13 @@ + + + + From f0695f8fd09488c68d2690b093e95019429e9964 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 23:10:46 -0400 Subject: [PATCH 4/8] fix(lab): center the Beta Features empty state The empty state was a Box wrapping its content, so `align(Center)` centered it against itself and it rendered flush at the top-left of the list. It now fills the viewport and centers there when it's the only thing in the list, and centers within padding when the override sections are present so it can't push them off screen. Text is centered and inset for narrow screens. --- .../app/lab/internal/LabsScreenContent.kt | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) 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 84701bb11f..f96702da35 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 @@ -1,5 +1,6 @@ package com.flipcash.app.lab.internal +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -26,6 +27,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute @@ -158,21 +160,36 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel, onboarding: Boole if (betaFlags.isEmpty()) { item(contentType = "empty_state") { - Box { + Box( + // With nothing else in the list the empty state owns the viewport and centers in + // it; when the override sections are showing it just centers in the gap they + // leave, so it can't push them off screen. + modifier = if (showAllFlags) { + Modifier + .fillMaxWidth() + .padding(vertical = CodeTheme.dimens.grid.x10) + } else { + Modifier.fillParentMaxSize() + }, + contentAlignment = Alignment.Center, + ) { Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), ) { Text( text = stringResource(R.string.title_labsAreEmpty), style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, ) Text( text = stringResource(R.string.subtitle_labsAreEmpty), style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, ) } } From 74b3333028073c64ee9e43719ba161ca3a01ff20 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 21 Aug 2026 09:57:02 -0400 Subject: [PATCH 5/8] fix(menu): drop the Beta badge from Beta Features and defer Change Display Name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row is literally called Beta Features — the badge next to it was redundant. Change Display Name comes out of My Account for now; promoting the UserProfile screen from a staff row to an always-visible one is its own change. The events and navigation that open it stay wired, so re-adding the row is a one-liner. --- .../internal/AdvancedFeatureMenuItems.kt | 1 - .../internal/myaccount/MyAccountMenuItems.kt | 17 ++++------------- .../myaccount/MyAccountScreenViewModel.kt | 3 ++- .../MyAccountScreenViewModelStateTest.kt | 6 ++---- 4 files changed, 8 insertions(+), 19 deletions(-) diff --git a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt index 7d83433885..be6303aaad 100644 --- a/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt +++ b/apps/flipcash/features/advanced/src/main/kotlin/com/flipcash/app/advanced/internal/AdvancedFeatureMenuItems.kt @@ -48,7 +48,6 @@ internal data object DeviceLogs : FullMenuItem() { - override val showBetaIndicator: Boolean = true override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Filled.Science) override val name: String diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt index caddab419e..3f4974c73c 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt @@ -1,7 +1,6 @@ package com.flipcash.app.myaccount.internal.myaccount import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ContactMail import androidx.compose.material.icons.outlined.Block import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter @@ -16,18 +15,10 @@ import com.flipcash.features.myaccount.R * Node 9277:121893. Account-shaped settings only — the destructive/diagnostic rows (Access Key, * Log Out, Delete Account) moved to Advanced, and the standalone App Settings screen folded its one * surviving toggle (Require Biometrics) in here. - */ -internal data object DisplayName : FullMenuItem() { - override val icon: Painter - @Composable get() = rememberVectorPainter(Icons.Default.ContactMail) - override val name: String - @Composable get() = stringResource(CoreR.string.title_changeDisplayName) - override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnContactMethodsClicked -} - -/** - * Toggle, not a destination — the screen renders a switch in its trailing slot and routes the tap - * through a biometric prompt. Its [action] is what a row tap dispatches, same as the switch. + * + * Require Biometrics is a toggle, not a destination — the screen renders a switch in its trailing + * slot and routes the tap through a biometric prompt. Its [action] is what a row tap dispatches, + * same as the switch. */ internal data object RequireBiometrics : FullMenuItem() { override val icon: Painter 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 8e6e4ff056..34b3e56179 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 @@ -14,7 +14,8 @@ import kotlinx.coroutines.flow.onEach import javax.inject.Inject private val FullMenuList = buildList { - add(DisplayName) + // Change Display Name is deferred to its own change; the UserProfile screen it opens stays + // wired below so re-adding the row is a one-liner. add(RequireBiometrics) add(Blocklist) } 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 3763e7ec47..79e0d1b728 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 @@ -1,7 +1,6 @@ package com.flipcash.app.myaccount.internal import com.flipcash.app.myaccount.internal.myaccount.Blocklist -import com.flipcash.app.myaccount.internal.myaccount.DisplayName import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreenViewModel import com.flipcash.app.myaccount.internal.myaccount.RequireBiometrics import kotlin.test.Test @@ -14,9 +13,9 @@ class MyAccountScreenViewModelStateTest { private val reduce = MyAccountScreenViewModel.Companion.updateStateForEvent @Test - fun `default state lists display name, biometrics and blocklist`() { + fun `default state lists biometrics and blocklist`() { val state = MyAccountScreenViewModel.State() - assertEquals(listOf(DisplayName, RequireBiometrics, Blocklist), state.items) + assertEquals(listOf(RequireBiometrics, Blocklist), state.items) assertFalse(state.biometricsRequired) } @@ -31,7 +30,6 @@ class MyAccountScreenViewModelStateTest { )(MyAccountScreenViewModel.State()) assertFalse(updated.items.any { it is RequireBiometrics }) - assertTrue(updated.items.any { it is DisplayName }) assertTrue(updated.items.any { it is Blocklist }) } From dc0a5d08e9fda8580daf818948fd4319e9a63a91 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 21 Aug 2026 10:32:07 -0400 Subject: [PATCH 6/8] feat(myaccount): restore User Profile for staff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row went away with Change Display Name, which left the User Profile screen unreachable — on code/cash staff could still get to it. It comes back as the staff-gated row it was, last in the list so the production list is unchanged, and the view model regains the beta-unlock wiring (feature-flag override or isStaff) that My Account lost in the redesign. Staff rows stay out of the default state so they can't flash in before the flag resolves. --- .../features/myaccount/build.gradle.kts | 1 + .../internal/myaccount/MyAccountMenuItems.kt | 15 ++++++ .../myaccount/MyAccountScreenViewModel.kt | 45 ++++++++++++++--- .../MyAccountScreenViewModelStateTest.kt | 49 +++++++++++++++++++ 4 files changed, 104 insertions(+), 6 deletions(-) diff --git a/apps/flipcash/features/myaccount/build.gradle.kts b/apps/flipcash/features/myaccount/build.gradle.kts index af429be46d..033f014091 100644 --- a/apps/flipcash/features/myaccount/build.gradle.kts +++ b/apps/flipcash/features/myaccount/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:menu")) + implementation(project(":apps:flipcash:shared:userflags")) implementation(project(":libs:datetime")) implementation(project(":libs:encryption:utils")) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt index 3f4974c73c..79b55721e8 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt @@ -1,6 +1,7 @@ package com.flipcash.app.myaccount.internal.myaccount import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContactMail import androidx.compose.material.icons.outlined.Block import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter @@ -8,6 +9,7 @@ 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 @@ -35,3 +37,16 @@ internal data object Blocklist : FullMenuItem() @Composable get() = stringResource(R.string.title_blocklist) override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnBlocklistClicked } + +/** + * Staff/beta only. Promoting this to an always-visible "Change Display Name" row is its own change; + * until then it stays where it was — behind the staff gate. + */ +internal data object UserProfile : StaffMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Default.ContactMail) + override val name: String + @Composable get() = stringResource(CoreR.string.title_userProfile) + override val action: MyAccountScreenViewModel.Event = + MyAccountScreenViewModel.Event.OnContactMethodsClicked +} 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 34b3e56179..edf6473ec3 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 @@ -3,10 +3,14 @@ package com.flipcash.app.myaccount.internal.myaccount import androidx.lifecycle.viewModelScope import com.flipcash.app.appsettings.AppSettingValue import com.flipcash.app.appsettings.AppSettingsCoordinator +import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.menu.MenuItem +import com.flipcash.app.menu.StaffMenuItem +import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.libs.coroutines.DispatcherProvider import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -14,15 +18,16 @@ import kotlinx.coroutines.flow.onEach import javax.inject.Inject private val FullMenuList = buildList { - // Change Display Name is deferred to its own change; the UserProfile screen it opens stays - // wired below so re-adding the row is a one-liner. add(RequireBiometrics) add(Blocklist) + add(UserProfile) } @HiltViewModel internal class MyAccountScreenViewModel @Inject constructor( private val appSettings: AppSettingsCoordinator, + featureFlagController: FeatureFlagController, + userFlags: UserFlagsCoordinator, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -35,10 +40,13 @@ internal class MyAccountScreenViewModel @Inject constructor( // isn't there, and shown-but-disabled when the hardware exists with nothing enrolled. val biometricsSupported: Boolean = true, val biometricsAvailable: Boolean = true, - val items: List> = FullMenuList, + val betaUnlocked: Boolean = false, + // Staff-only rows stay out until the real flag state loads, so they never flash in. + val items: List> = FullMenuList.filterNot { it is StaffMenuItem }, ) internal sealed interface Event { + data class OnBetaFeaturesUnlocked(val unlocked: Boolean) : Event data class OnBiometricsSettingChanged( val required: Boolean, val supported: Boolean, @@ -53,6 +61,13 @@ internal class MyAccountScreenViewModel @Inject constructor( } init { + combine( + featureFlagController.observeOverride(), + userFlags.resolvedFlags.map { it.isStaff.effectiveValue }, + ) { override, isStaff -> override || isStaff } + .onEach { dispatchEvent(Event.OnBetaFeaturesUnlocked(it)) } + .launchIn(viewModelScope) + appSettings.settings() .map { items -> items.find { it.setting.type == AppSettingValue.BiometricsRequired } } .onEach { item -> @@ -89,8 +104,13 @@ internal class MyAccountScreenViewModel @Inject constructor( } internal companion object { - private fun buildItemList(biometricsSupported: Boolean): List> = - FullMenuList.filterNot { it == RequireBiometrics && !biometricsSupported } + /** Biometrics drops out on hardware that can't offer it; staff rows need the beta unlock. */ + private fun buildItemList( + biometricsSupported: Boolean, + betaUnlocked: Boolean, + ): List> = FullMenuList + .filterNot { it == RequireBiometrics && !biometricsSupported } + .filter { it !is StaffMenuItem || betaUnlocked } val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { @@ -100,12 +120,25 @@ internal class MyAccountScreenViewModel @Inject constructor( Event.OnBlocklistClicked, Event.OnViewBlocklist -> { state -> state } + is Event.OnBetaFeaturesUnlocked -> { state -> + state.copy( + betaUnlocked = event.unlocked, + items = buildItemList( + biometricsSupported = state.biometricsSupported, + betaUnlocked = event.unlocked, + ), + ) + } + is Event.OnBiometricsSettingChanged -> { state -> state.copy( biometricsRequired = event.required, biometricsSupported = event.supported, biometricsAvailable = event.available, - items = buildItemList(event.supported), + items = buildItemList( + biometricsSupported = event.supported, + betaUnlocked = state.betaUnlocked, + ), ) } } 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 79e0d1b728..5f65567644 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,6 +3,7 @@ package com.flipcash.app.myaccount.internal import com.flipcash.app.myaccount.internal.myaccount.Blocklist import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreenViewModel import com.flipcash.app.myaccount.internal.myaccount.RequireBiometrics +import com.flipcash.app.myaccount.internal.myaccount.UserProfile import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -48,6 +49,54 @@ class MyAccountScreenViewModelStateTest { assertTrue(updated.biometricsAvailable) } + @Test + fun `user profile stays hidden until beta features unlock`() { + val locked = MyAccountScreenViewModel.State() + assertFalse(locked.items.any { it is UserProfile }) + + val unlocked = reduce( + MyAccountScreenViewModel.Event.OnBetaFeaturesUnlocked(unlocked = true) + )(locked) + + assertTrue(unlocked.items.any { it is UserProfile }) + assertTrue(unlocked.items.any { it is RequireBiometrics }) + } + + @Test + fun `unlocking beta keeps an unsupported biometrics row hidden`() { + val noBiometrics = reduce( + MyAccountScreenViewModel.Event.OnBiometricsSettingChanged( + required = false, + supported = false, + available = false, + ) + )(MyAccountScreenViewModel.State()) + + val unlocked = reduce( + MyAccountScreenViewModel.Event.OnBetaFeaturesUnlocked(unlocked = true) + )(noBiometrics) + + assertFalse(unlocked.items.any { it is RequireBiometrics }) + assertTrue(unlocked.items.any { it is UserProfile }) + } + + @Test + fun `biometrics changes keep an unlocked user profile visible`() { + val unlocked = reduce( + MyAccountScreenViewModel.Event.OnBetaFeaturesUnlocked(unlocked = true) + )(MyAccountScreenViewModel.State()) + + val updated = reduce( + MyAccountScreenViewModel.Event.OnBiometricsSettingChanged( + required = true, + supported = true, + available = true, + ) + )(unlocked) + + assertTrue(updated.items.any { it is UserProfile }) + } + @Test fun `no-op events return state unchanged`() { val state = MyAccountScreenViewModel.State(biometricsRequired = true) From 54342676a19f8089747c5d9339833024291a1fba Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 21 Aug 2026 13:51:30 -0400 Subject: [PATCH 7/8] fix(myaccount): badge User Profile as Beta, restore the biometrics row's unavailable state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions from the settings restyle, both in the slot-driven MenuList overload that My Account uses for its toggle row: - the overload never forwarded MenuItem.showBetaIndicator, so User Profile — a StaffMenuItem, gated behind the same `override || isStaff` unlock it always had — rendered without its Beta badge. The badge is now emitted by the overload itself, ahead of whatever trailing content the caller supplies. - the old SettingsSwitchRow dimmed its content and explained itself when a toggle wasn't offerable. Require Biometrics on hardware with nothing enrolled had been rendering as a live row that silently did nothing. ListItem takes `enabled` and `supportingText` again, and the mapper's existing "No biometrics currently enrolled" description is plumbed through MyAccountScreenViewModel.State. --- .../myaccount/MyAccountScreenContent.kt | 8 +++ .../myaccount/MyAccountScreenViewModel.kt | 6 +++ .../MyAccountScreenViewModelStateTest.kt | 40 +++++++++++++++ .../kotlin/com/flipcash/app/menu/MenuList.kt | 21 +++++++- .../com/getcode/ui/components/ListItem.kt | 50 +++++++++++++++---- 5 files changed, 115 insertions(+), 10 deletions(-) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenContent.kt index 0ab8b6b744..95c4d20ff3 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenContent.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.menu.MenuList import com.getcode.libs.biometrics.Biometrics @@ -43,6 +44,13 @@ private fun MyAccountScreenContent( MenuList( modifier = Modifier.fillMaxSize(), items = state.items, + // Only the biometrics row can be inert, and only when the hardware has nothing enrolled. + isItemEnabled = { item -> item != RequireBiometrics || state.biometricsAvailable }, + supportingTextFor = { item -> + state.biometricsDescription + ?.takeIf { item == RequireBiometrics } + ?.let { stringResource(it) } + }, onItemClick = { item -> if (item == RequireBiometrics) toggleBiometrics() else dispatch(item.action) }, 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 edf6473ec3..d1ee107b1c 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 @@ -1,5 +1,6 @@ package com.flipcash.app.myaccount.internal.myaccount +import androidx.annotation.StringRes import androidx.lifecycle.viewModelScope import com.flipcash.app.appsettings.AppSettingValue import com.flipcash.app.appsettings.AppSettingsCoordinator @@ -40,6 +41,8 @@ internal class MyAccountScreenViewModel @Inject constructor( // isn't there, and shown-but-disabled when the hardware exists with nothing enrolled. val biometricsSupported: Boolean = true, val biometricsAvailable: Boolean = true, + // Why the row can't act, when it can't — e.g. the hardware is there with nothing enrolled. + @StringRes val biometricsDescription: Int? = null, val betaUnlocked: Boolean = false, // Staff-only rows stay out until the real flag state loads, so they never flash in. val items: List> = FullMenuList.filterNot { it is StaffMenuItem }, @@ -51,6 +54,7 @@ internal class MyAccountScreenViewModel @Inject constructor( val required: Boolean, val supported: Boolean, val available: Boolean, + @StringRes val description: Int? = null, ) : Event /** Dispatched only after the screen's biometric prompt succeeds. */ data object OnBiometricsToggled : Event @@ -77,6 +81,7 @@ internal class MyAccountScreenViewModel @Inject constructor( required = item.setting.enabled, supported = item.visible, available = item.available, + description = item.description, ) ) }.launchIn(viewModelScope) @@ -135,6 +140,7 @@ internal class MyAccountScreenViewModel @Inject constructor( biometricsRequired = event.required, biometricsSupported = event.supported, biometricsAvailable = event.available, + biometricsDescription = event.description, items = buildItemList( biometricsSupported = event.supported, betaUnlocked = state.betaUnlocked, 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 5f65567644..a4bf199990 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 @@ -97,6 +97,46 @@ class MyAccountScreenViewModelStateTest { assertTrue(updated.items.any { it is UserProfile }) } + @Test + fun `unavailable biometrics carries the explanation through`() { + val updated = reduce( + MyAccountScreenViewModel.Event.OnBiometricsSettingChanged( + required = false, + supported = true, + available = false, + description = 42, + ) + )(MyAccountScreenViewModel.State()) + + assertTrue(updated.items.any { it is RequireBiometrics }) + assertFalse(updated.biometricsAvailable) + assertEquals(42, updated.biometricsDescription) + } + + @Test + fun `enrolling biometrics clears the explanation`() { + val unavailable = reduce( + MyAccountScreenViewModel.Event.OnBiometricsSettingChanged( + required = false, + supported = true, + available = false, + description = 42, + ) + )(MyAccountScreenViewModel.State()) + + val enrolled = reduce( + MyAccountScreenViewModel.Event.OnBiometricsSettingChanged( + required = false, + supported = true, + available = true, + description = null, + ) + )(unavailable) + + assertTrue(enrolled.biometricsAvailable) + assertEquals(null, enrolled.biometricsDescription) + } + @Test fun `no-op events return state unchanged`() { val state = MyAccountScreenViewModel.State(biometricsRequired = true) diff --git a/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuList.kt b/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuList.kt index faf707edcc..c3001365ce 100644 --- a/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuList.kt +++ b/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuList.kt @@ -3,6 +3,8 @@ package com.flipcash.app.menu import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items @@ -10,6 +12,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.BetaIndicator import com.getcode.ui.components.ListItem import com.getcode.ui.core.verticalScrollStateGradient import com.getcode.ui.utils.sheetResignmentBehavior @@ -60,6 +64,9 @@ fun MenuList( /** * Slot-driven variant: the caller renders each row's trailing content via [endSlot] (chevron, * loading spinner, etc.). Use this when a row needs a stateful trailing indicator. + * + * [isItemEnabled] and [supportingTextFor] let a row report that it can't act right now and say + * why — the biometrics row on a device with nothing enrolled, for instance. */ @Composable fun MenuList( @@ -69,6 +76,8 @@ fun MenuList( header: @Composable (() -> Unit)? = null, footer: @Composable (() -> Unit)? = null, contentPadding: PaddingValues = PaddingValues(0.dp), + isItemEnabled: (MenuItem) -> Boolean = { true }, + supportingTextFor: @Composable (MenuItem) -> String? = { null }, onItemClick: (MenuItem) -> Unit, endSlot: @Composable RowScope.(MenuItem) -> Unit, ) { @@ -89,8 +98,18 @@ fun MenuList( headline = item.name, icon = item.icon, modifier = Modifier.animateItem(), + enabled = isItemEnabled(item), + supportingText = supportingTextFor(item), onClick = { onItemClick(item) }, - endSlot = { endSlot(item) }, + endSlot = { + // The badge stays this overload's job so a staff row is marked the same way + // whether or not the caller supplies its own trailing content. + if (item.showBetaIndicator) { + BetaIndicator() + Spacer(Modifier.width(CodeTheme.dimens.grid.x2)) + } + endSlot(item) + }, ) } if (footer != null) { 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 1f7faebfc6..d109b9caa3 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 @@ -1,6 +1,8 @@ package com.getcode.ui.components +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer @@ -14,6 +16,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter @@ -28,6 +31,9 @@ import androidx.compose.foundation.clickable private val ListItemIconSize = 24.dp +/** Matches the dimming the old settings switch row applied when a toggle wasn't offerable. */ +private const val DisabledContentAlpha = 0.38f + /** 17sp Demi, per the settings rows in node 9276:4634 — not the 20sp of a section headline. */ private val ListItemHeadlineStyle @Composable get() = CodeTheme.typography.textMedium.copy(fontSize = 17.sp, lineHeight = 22.sp) @@ -36,12 +42,18 @@ private val ListItemHeadlineStyle * Slot-based list row: icon + headline, with the caller driving the trailing [endSlot] — chevron, * loading spinner, beta badge, or any combination. Prefer this overload when the trailing content * is stateful (e.g. swaps to a spinner while the row's action is in flight). + * + * A row that can't act right now passes [enabled] = false: the tap is swallowed and the icon and + * headline dim, so the row reads as unavailable rather than broken. [supportingText] is where it + * says why (e.g. biometrics with nothing enrolled). */ @Composable fun ListItem( headline: String, icon: Painter?, modifier: Modifier = Modifier, + enabled: Boolean = true, + supportingText: String? = null, onClick: () -> Unit, endSlot: @Composable RowScope.() -> Unit, ) { @@ -49,29 +61,49 @@ fun ListItem( modifier = modifier .fillMaxWidth() .wrapContentHeight() - .clickable { onClick() } + .clickable(enabled = enabled) { onClick() } .padding(CodeTheme.dimens.grid.x5), verticalAlignment = CenterVertically ) { + val contentColor by animateColorAsState( + targetValue = CodeTheme.colors.textMain + .copy(alpha = if (enabled) 1f else DisabledContentAlpha), + label = "listItemContentColor", + ) + val supportingColor by animateColorAsState( + targetValue = CodeTheme.colors.textSecondary + .copy(alpha = if (enabled) 1f else DisabledContentAlpha), + label = "listItemSupportingColor", + ) + if (icon != null) { Image( modifier = Modifier .padding(end = CodeTheme.dimens.grid.x4) .size(ListItemIconSize), painter = icon, - colorFilter = ColorFilter.tint(CodeTheme.colors.onBackground), + colorFilter = ColorFilter.tint(contentColor), contentDescription = "" ) } - Text( - modifier = Modifier.align(CenterVertically), - text = headline, - style = ListItemHeadlineStyle, - color = CodeTheme.colors.textMain, - ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = headline, + style = ListItemHeadlineStyle, + color = contentColor, + ) + + if (!supportingText.isNullOrEmpty()) { + Text( + text = supportingText, + style = CodeTheme.typography.textSmall, + color = supportingColor, + ) + } + } - Spacer(Modifier.weight(1f)) + Spacer(Modifier.width(CodeTheme.dimens.grid.x2)) endSlot() } From 9ebf0339c332408a07c66b98292317bc8742adea Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 21 Aug 2026 13:57:44 -0400 Subject: [PATCH 8/8] chore(appsettings): drop the dead Auto Start Camera resources The setting itself, its mapper entry and its session-controller consumer went with the standalone App Settings screen; its title string and icon outlived it with no references left. --- .../core/src/main/res/drawable/ic_camera_outline.xml | 12 ------------ .../src/main/res/values/strings-localized.xml | 1 - 2 files changed, 13 deletions(-) delete mode 100644 apps/flipcash/core/src/main/res/drawable/ic_camera_outline.xml diff --git a/apps/flipcash/core/src/main/res/drawable/ic_camera_outline.xml b/apps/flipcash/core/src/main/res/drawable/ic_camera_outline.xml deleted file mode 100644 index 60b4f3dd95..0000000000 --- a/apps/flipcash/core/src/main/res/drawable/ic_camera_outline.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/ui/resources/src/main/res/values/strings-localized.xml b/ui/resources/src/main/res/values/strings-localized.xml index 171eafccef..edadd5db20 100644 --- a/ui/resources/src/main/res/values/strings-localized.xml +++ b/ui/resources/src/main/res/values/strings-localized.xml @@ -316,7 +316,6 @@ Access Expired Access Key App Settings - Auto Start Camera Balance Beta Features Bonus