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/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 173d38db0a..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 @@ -28,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 @@ -178,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)) { @@ -282,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 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/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/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/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..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 @@ -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,38 @@ 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() } + .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..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 @@ -6,11 +6,29 @@ 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.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 + +/** + * 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 +55,37 @@ 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 +} + +/** + * Staff/beta only, and dead without Google's Password Manager behind it — + * `PassphraseCredentialManager.selectCredential()` refuses outright when the flag is off. Sits here + * rather than on the You tab: it's a beta tool, next to the other one. + */ +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: 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 dbb6f1b25b..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 @@ -1,30 +1,48 @@ 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 +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(SwitchAccount) + add(LogOut) + add(DeleteAccount) } @HiltViewModel internal class AdvancedFeaturesScreenViewModel @Inject constructor( featureFlagController: FeatureFlagController, userFlags: UserFlagsCoordinator, + resources: ResourceHelper, + authManager: AuthManager, + mnemonicManager: MnemonicManager, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -33,37 +51,160 @@ 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 + data object OnLogOutClicked : Event + data object OnLoggedOutCompletely : Event + data object OnDeleteAccountClicked : Event + data object OnAccountDeleted : Event } 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 { + 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 { + /** + * 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 -> { state -> state } - is Event.OpenBillPlayground -> { state -> state } + is Event.OpenScreen, + Event.OnSwitchAccountsClicked, + is Event.OnSwitchAccountTo, + 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/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, ) } } 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/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/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..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,14 +6,12 @@ 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() { 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,30 +21,10 @@ 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( AppRoute.Menu.AdvancedFeatures ) } - -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) - 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 a3e73a51a3..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,56 +1,92 @@ 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 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.lazy.rememberLazyListState +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.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 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.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 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 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 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 @@ -67,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() @@ -76,13 +126,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). @@ -97,74 +150,207 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { } } ) { padding -> - MenuList( + BoxWithConstraints( modifier = Modifier .fillMaxSize() .padding(padding), - items = state.items, - header = { - if (isNewUi) { - YouHeader( - card = state.tipCard, - onShare = { viewModel.dispatchEvent(Event.ShareTipCard) }, - ) - } 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( - top = CodeTheme.dimens.grid.x3, - bottom = LocalTabBarPadding.current.calculateBottomPadding(), - ), - onItemClick = { - viewModel.dispatchEvent(it.action) + // 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) + } + }, + 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 }, + ) + } + } } } +/** 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 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 + /** - * 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). + * 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?, onShare: () -> Unit) { +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 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", - ) 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 +360,237 @@ private fun YouHeader(card: Scannable.TipCard?, onShare: () -> Unit) { ) { Box( modifier = Modifier - .graphicsLayer { alpha = cardAlpha } - .noRippleClickable { session.presentOwnTipCard(card) }, + // 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) + .onGloballyPositioned { onCardSlotPositioned(it.boundsInRoot().center.y) }, contentAlignment = Alignment.Center, ) { - ScannableRenderer(scannable = card, tipCardWidth = 230.dp) + Box( + modifier = Modifier + .graphicsLayer { translationY = cardShift } + .noRippleClickable { onToggleFullScreen() }, + ) { + ScannableRenderer(scannable = card, tipCardWidth = cardWidth) + } + } + } + + Spacer(Modifier.height(CodeTheme.dimens.grid.x6)) + + // 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 = slideAway.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(64.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.grid.x5), + verticalArrangement = Arrangement.spacedBy(11.dp), + ) { + 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)) } + } +} +/** + * 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 - .clip(CircleShape) - .background(CodeTheme.colors.surfaceVariant) - .clickable { onShare() } - .padding( - horizontal = CodeTheme.dimens.grid.x4, - vertical = CodeTheme.dimens.grid.x3, + .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, 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(enabled = enabled) { + onCopy() + copyToken++ + } + .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, + ) + } + // 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 ), - text = stringResource(R.string.action_shareAsLink), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, + 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 + * 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, + icon: Int, + label: String, + enabled: Boolean, + onClick: () -> Unit, +) { + Column( + modifier = modifier + .fillMaxSize() + .clip(TileShape) + .background(White05) + .clickable(enabled = enabled) { onClick() }, + 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( @@ -235,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 b38fa986c6..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 @@ -1,14 +1,18 @@ 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 -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 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 @@ -24,8 +28,8 @@ 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 import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel @@ -44,18 +48,14 @@ import javax.inject.Inject private val FullMenuList = buildList { add(MyAccount) - add(AppSettings) 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, @@ -65,6 +65,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 +85,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 { @@ -95,10 +99,11 @@ 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) : 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 { @@ -162,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 { @@ -195,11 +183,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,15 +323,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, - is Event.OpenScreen, - is Event.OnSwitchAccountTo -> { state -> state } + Event.CopyTipLink, + Event.DownloadTipCard, + is Event.ExportTipCard, + is Event.OpenScreen -> { state -> state } is Event.OnFeatureFlagsUpdated -> { state -> state.copy( 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..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 @@ -3,9 +3,14 @@ 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 kotlinx.coroutines.launch @Composable internal fun MyAccountScreen(viewModel: MyAccountScreenViewModel) { @@ -19,10 +24,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 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 { + 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) { + ListItemDefaults.Toggle( + checked = state.biometricsRequired, + enabled = state.biometricsAvailable, + ) + } 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..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 @@ -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 -> TipCardDecorator(scannable) } } } 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/SessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt index af8328ef6b..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 { @@ -66,7 +59,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/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..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 - // goes through BillOperations.presentOwnTipCard (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/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..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 @@ -7,6 +7,7 @@ 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 +19,19 @@ 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 com.getcode.ui.theme.CodeToggleSwitch 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 +56,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 +67,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 +110,37 @@ 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, + ) + } + + /** + * 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, + ) + } +} 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_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 @@ + + + + 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 @@ + + + + +