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 c0a1d2a11..8b8acc655 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 @@ -28,11 +28,11 @@ import com.flipcash.app.contact.verification.VerificationFlowScreen import com.flipcash.app.currencycreator.CurrencyCreatorFlowScreen import com.flipcash.app.core.AppRoute import com.flipcash.app.currency.RegionSelectionScreen -import com.flipcash.app.deposit.DepositDestinationScreen import com.flipcash.app.deposit.DepositFlowScreen import com.flipcash.app.discovery.TokenDiscoveryScreen import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator import com.flipcash.app.lab.LabsScreen +import com.flipcash.app.lab.NavBarSettingsScreen import com.flipcash.app.lab.StandaloneLabsScreen import com.flipcash.app.login.accesskey.AccessKeyScreen import com.flipcash.app.login.accesskey.PhotoAccessKeyScreen @@ -125,6 +125,7 @@ fun appEntryProvider( // Menu annotatedEntry { AppSettingsScreen() } annotatedEntry { LabsScreen() } + annotatedEntry { NavBarSettingsScreen() } annotatedEntry { MyAccountScreen() } annotatedEntry { BackupKeyScreen() } annotatedEntry { AdvancedFeaturesScreen() } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 56800e490..5dcf0cb06 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 @@ -193,6 +193,8 @@ sealed interface AppRoute : NavKey, Parcelable { data object DeviceLogs : Menu @Serializable data object Lab : Menu + @Serializable + data object NavBarSettings : Menu, com.getcode.navigation.Sheet, com.getcode.navigation.WrapContentSheet } @Serializable diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/GiveButtonLabel.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/GiveButtonLabel.kt new file mode 100644 index 000000000..4c3afbbab --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/GiveButtonLabel.kt @@ -0,0 +1,9 @@ +package com.flipcash.app.core.navigation + +import androidx.annotation.StringRes +import com.flipcash.core.R + +enum class GiveButtonLabel(@StringRes val labelRes: Int) { + Give(R.string.action_give), + Cash(R.string.action_cash), +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarButton.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarButton.kt new file mode 100644 index 000000000..df7647e53 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarButton.kt @@ -0,0 +1,12 @@ +package com.flipcash.app.core.navigation + +enum class NavBarButton { + Give, + Wallet, + Discover, + ; + + companion object { + val defaultOrder = listOf(Give, Wallet, Discover) + } +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarConfig.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarConfig.kt new file mode 100644 index 000000000..610611773 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarConfig.kt @@ -0,0 +1,27 @@ +package com.flipcash.app.core.navigation + +data class NavBarConfig( + val order: List = NavBarButton.defaultOrder, + val giveButtonLabel: GiveButtonLabel = GiveButtonLabel.Give, +) { + fun serialize(): String = + "${order.joinToString(",") { it.name }}|${giveButtonLabel.name}" + + companion object { + val Default = NavBarConfig() + + fun deserialize(value: String): NavBarConfig { + if (value.isBlank()) return Default + val parts = value.split("|") + val order = parts.getOrNull(0) + ?.split(",") + ?.mapNotNull { runCatching { NavBarButton.valueOf(it) }.getOrNull() } + ?.ifEmpty { NavBarButton.defaultOrder } + ?: NavBarButton.defaultOrder + val label = parts.getOrNull(1) + ?.let { runCatching { GiveButtonLabel.valueOf(it) }.getOrNull() } + ?: GiveButtonLabel.Give + return NavBarConfig(order, label) + } + } +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/LongPressDraggable.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/LongPressDraggable.kt new file mode 100644 index 000000000..41c0d4e68 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/LongPressDraggable.kt @@ -0,0 +1,128 @@ +package com.flipcash.app.core.ui + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateIntAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.zIndex +import kotlin.math.roundToInt + +class LongPressDraggableState internal constructor( + internal val itemCount: Int, +) { + internal val draggingIndex = mutableIntStateOf(-1) + internal val dragOffsetX = mutableFloatStateOf(0f) + internal val itemWidthPx = mutableIntStateOf(0) + internal var onReorder: (from: Int, to: Int) -> Unit = { _, _ -> } +} + +/** + * @param key When this key changes the drag state resets. Pass the current order + * so that after a reorder propagates, the state clears atomically + * with the new layout positions. + */ +@Composable +fun rememberLongPressDraggableState( + itemCount: Int, + key: Any? = null, + onReorder: (from: Int, to: Int) -> Unit, +): LongPressDraggableState { + val state = remember(itemCount, key) { LongPressDraggableState(itemCount) } + state.onReorder = onReorder + return state +} + +@Composable +fun Modifier.longPressDraggable( + state: LongPressDraggableState, + index: Int, +): Modifier { + val displacement by remember(state, index) { + derivedStateOf { + val currentDragging = state.draggingIndex.intValue + if (currentDragging == -1 || currentDragging == index) { + 0 + } else { + val w = state.itemWidthPx.intValue + if (w <= 0) 0 + else { + val draggedVisualSlot = (currentDragging + + (state.dragOffsetX.floatValue / w).roundToInt()) + .coerceIn(0, state.itemCount - 1) + when { + currentDragging < draggedVisualSlot && + index in (currentDragging + 1)..draggedVisualSlot -> -w + currentDragging > draggedVisualSlot && + index in draggedVisualSlot until currentDragging -> w + else -> 0 + } + } + } + } + } + val animatedDisplacement by animateIntAsState( + targetValue = displacement, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + ) + + return this + .zIndex(if (state.draggingIndex.intValue == index) 1f else 0f) + .offset { + val currentDragging = state.draggingIndex.intValue + val dx = when { + // Dragged item follows finger directly + currentDragging == index -> state.dragOffsetX.floatValue.roundToInt() + // Non-dragged items animate during an active drag + currentDragging != -1 -> animatedDisplacement + // No drag active — snap to natural position + else -> 0 + } + IntOffset(dx, 0) + } + .onSizeChanged { state.itemWidthPx.intValue = it.width } + .pointerInput(state) { + detectDragGesturesAfterLongPress( + onDragStart = { + state.draggingIndex.intValue = index + state.dragOffsetX.floatValue = 0f + }, + onDrag = { change, dragAmount -> + change.consume() + state.dragOffsetX.floatValue += dragAmount.x + }, + onDragEnd = { + val w = state.itemWidthPx.intValue + if (w > 0) { + val from = state.draggingIndex.intValue + val to = (from + (state.dragOffsetX.floatValue / w).roundToInt()) + .coerceIn(0, state.itemCount - 1) + if (from != to) { + // Snap offset to exact target so item stays visually + // in place until the reorder propagates and the state + // resets via key change. + state.dragOffsetX.floatValue = (to - from).toFloat() * w + state.onReorder(from, to) + return@detectDragGesturesAfterLongPress + } + } + state.draggingIndex.intValue = -1 + state.dragOffsetX.floatValue = 0f + }, + onDragCancel = { + state.draggingIndex.intValue = -1 + state.dragOffsetX.floatValue = 0f + }, + ) + } +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt new file mode 100644 index 000000000..e086cfdd5 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt @@ -0,0 +1,246 @@ +package com.flipcash.app.core.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.navigation.NavBarButton +import com.flipcash.app.core.navigation.NavBarConfig +import com.flipcash.core.R +import com.getcode.theme.CodeTheme +import com.getcode.theme.xxl +import com.getcode.ui.components.Badge +import com.getcode.ui.components.Pill +import com.getcode.ui.core.unboundedClickable +import com.getcode.ui.utils.heightOrZero +import com.getcode.ui.utils.widthOrZero + +data class NavigationBarState( + val notificationUnreadCount: Int = 0, + val showToast: Boolean = false, + val toastText: String? = null, + val isPaused: Boolean = false, +) + +@Composable +fun NavigationBar( + modifier: Modifier = Modifier, + config: NavBarConfig = NavBarConfig.Default, + state: NavigationBarState = NavigationBarState(), + onButtonClick: (NavBarButton) -> Unit = {}, + onOrderChanged: ((List) -> Unit)? = null, +) { + val reorderState = onOrderChanged?.let { + rememberLongPressDraggableState( + itemCount = config.order.size, + key = config.order, + onReorder = { from, to -> + val newOrder = config.order.toMutableList() + val item = newOrder.removeAt(from) + newOrder.add(to, item) + onOrderChanged(newOrder) + }, + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .then(modifier), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceAround, + ) { + config.order.forEachIndexed { index, button -> + val buttonModifier = if (reorderState != null) { + Modifier.weight(1f).longPressDraggable(reorderState, index) + } else { + Modifier.weight(1f) + } + + when (button) { + NavBarButton.Give -> BottomBarAction( + modifier = buttonModifier, + label = stringResource(config.giveButtonLabel.labelRes), + painter = painterResource(R.drawable.ic_cash_bill), + badgeCount = 0, + onClick = { onButtonClick(NavBarButton.Give) } + ) + NavBarButton.Wallet -> BottomBarAction( + modifier = buttonModifier, + label = stringResource(R.string.action_wallet), + painter = painterResource(R.drawable.ic_flipcash_balance), + badgeCount = state.notificationUnreadCount, + onClick = { onButtonClick(NavBarButton.Wallet) }, + toast = { + AnimatedVisibility( + visible = state.showToast && state.toastText != null, + enter = slideInVertically(animationSpec = tween(600), initialOffsetY = { it }) + + fadeIn(animationSpec = tween(500, 100)), + exit = if (!state.isPaused) + slideOutVertically(animationSpec = tween(600), targetOffsetY = { it }) + + fadeOut(animationSpec = tween(500, 100)) + else fadeOut(animationSpec = tween(0)), + ) { + val toastText by remember(state.toastText) { + derivedStateOf { state.toastText } + } + Pill( + text = toastText.orEmpty(), + textStyle = CodeTheme.typography.textSmall.copy( + fontWeight = FontWeight.Bold + ), + shape = CodeTheme.shapes.xxl, + ) + } + } + ) + NavBarButton.Discover -> BottomBarAction( + modifier = buttonModifier, + label = stringResource(R.string.action_discover), + painter = painterResource(R.drawable.ic_coins), + badgeCount = 0, + onClick = { onButtonClick(NavBarButton.Discover) } + ) + } + } + } +} + +@Composable +private fun BottomBarAction( + painter: Painter, + label: String, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues( + vertical = CodeTheme.dimens.grid.x2 + ), + imageSize: Dp = CodeTheme.dimens.staticGrid.x10, + toast: @Composable () -> Unit = { }, + badgeCount: Int = 0, + onClick: (() -> Unit)?, +) { + Column( + modifier = modifier.width(IntrinsicSize.Max), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + toast() + BottomBarAction( + label = label, + contentPadding = contentPadding, + painter = painter, + imageSize = imageSize, + badge = { + Badge( + modifier = Modifier.padding(top = 6.dp, end = 1.dp), + count = badgeCount, + color = CodeTheme.colors.indicator, + enterTransition = scaleIn( + animationSpec = tween( + durationMillis = 300, + delayMillis = 1000 + ) + ) + fadeIn() + ) + }, + onClick = onClick + ) + } +} + +@Composable +private fun BottomBarAction( + modifier: Modifier = Modifier, + label: String, + contentPadding: PaddingValues = PaddingValues( + vertical = CodeTheme.dimens.grid.x2 + ), + painter: Painter, + iconColor: Color = Color.White, + textColor: Color = Color.White, + imageSize: Dp = CodeTheme.dimens.staticGrid.x10, + badge: @Composable () -> Unit = { }, + onClick: (() -> Unit)?, +) { + Layout( + modifier = modifier, + content = { + Column( + modifier = Modifier + .unboundedClickable( + enabled = onClick != null, + rippleRadius = imageSize + ) { onClick?.invoke() } + .layoutId("action"), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + modifier = Modifier + .padding(contentPadding) + .size(imageSize), + painter = painter, + colorFilter = ColorFilter.tint(iconColor), + contentDescription = null, + ) + Text( + text = label, + style = CodeTheme.typography.textSmall, + color = textColor + ) + } + + Box(modifier = Modifier.layoutId("badge")) { + badge() + } + } + ) { measurables, incomingConstraints -> + val constraints = incomingConstraints.copy(minWidth = 0, minHeight = 0) + val actionPlaceable = + measurables.find { it.layoutId == "action" }?.measure(constraints) + val badgePlaceable = + measurables.find { it.layoutId == "badge" }?.measure(constraints) + + val maxWidth = widthOrZero(actionPlaceable) + val maxHeight = heightOrZero(actionPlaceable) + layout( + width = maxWidth, + height = maxHeight, + ) { + actionPlaceable?.placeRelative(0, 0) + badgePlaceable?.placeRelative( + x = maxWidth - widthOrZero(badgePlaceable), + y = -(heightOrZero(badgePlaceable) / 3) + ) + } + } +} diff --git a/apps/flipcash/core/src/main/res/drawable/ic_bottom_navigation.xml b/apps/flipcash/core/src/main/res/drawable/ic_bottom_navigation.xml new file mode 100644 index 000000000..449874a06 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_bottom_navigation.xml @@ -0,0 +1,24 @@ + + + + diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 0e51936fe..b17811661 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -519,11 +519,16 @@ Check back soon Features + Home Screen Developer User Flags Account User Flags + Button Order + Hold and drag to reorder + Give Button Label + Purchase Connect Your Phantom Wallet diff --git a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/NavBarSettingsScreen.kt b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/NavBarSettingsScreen.kt new file mode 100644 index 000000000..f593d15e5 --- /dev/null +++ b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/NavBarSettingsScreen.kt @@ -0,0 +1,31 @@ +package com.flipcash.app.lab + +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 com.flipcash.app.lab.internal.NavBarSettingsContent +import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle + +@Composable +fun NavBarSettingsScreen() { + val dismiss = LocalBottomSheetDismissDispatcher.current + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = "", + titleAlignment = Alignment.CenterHorizontally, + isInModal = true, + endContent = { + AppBarDefaults.Close { dismiss() } + } + ) + + NavBarSettingsContent() + } +} 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 109a21681..b34fad121 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 @@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MarkEmailUnread +import androidx.compose.material.icons.filled.Navigation import androidx.compose.material.icons.filled.PhonelinkErase import androidx.compose.material.icons.filled.Token import androidx.compose.material3.HorizontalDivider @@ -20,6 +21,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment 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.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -87,6 +89,16 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel) { ) } + item { SectionHeader(stringResource(R.string.title_settingsSectionHomeScreen)) } + item { + ListItem( + headline = stringResource(R.string.title_settingsButtonOrder), + icon = painterResource(R.drawable.ic_bottom_navigation), + ) { + navigator.navigate(AppRoute.Menu.NavBarSettings) + } + } + if (betaFlags.isEmpty()) { item { Box { diff --git a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/NavBarSettingsContent.kt b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/NavBarSettingsContent.kt new file mode 100644 index 000000000..a334f33ab --- /dev/null +++ b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/NavBarSettingsContent.kt @@ -0,0 +1,86 @@ +package com.flipcash.app.lab.internal + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.navigation.GiveButtonLabel +import com.flipcash.app.core.navigation.NavBarConfig +import com.flipcash.app.core.ui.NavigationBar +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.LocalFeatureFlags +import com.flipcash.core.R +import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 +import com.getcode.ui.components.text.SectionHeader +import com.getcode.ui.theme.CodeSegmentedControl + +@Composable +internal fun NavBarSettingsContent() { + val featureFlags = LocalFeatureFlags.current + val configString by featureFlags.getOption(FeatureFlag.NavBar) + .collectAsStateWithLifecycle() + val config = remember(configString) { NavBarConfig.deserialize(configString) } + + Column( + modifier = Modifier + .wrapContentHeight(), + ) { + Text( + text = stringResource(R.string.subtitle_settingsButtonOrder), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x1) + .clip(CodeTheme.shapes.large) + .background(White05) + .padding(vertical = CodeTheme.dimens.grid.x4), + contentAlignment = Alignment.Center, + ) { + NavigationBar( + config = config, + onOrderChanged = { newOrder -> + val updated = config.copy(order = newOrder) + featureFlags.setOption(FeatureFlag.NavBar, updated.serialize()) + }, + ) + } + + SectionHeader(stringResource(R.string.title_settingsSectionGiveButtonLabel)) + + CodeSegmentedControl( + options = GiveButtonLabel.entries, + selected = config.giveButtonLabel, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x3) + .navigationBarsPadding(), + mapper = { option -> + Text(text = stringResource(option.labelRes)) + }, + onSelectionChanged = { label -> + val updated = config.copy(giveButtonLabel = label) + featureFlags.setOption(FeatureFlag.NavBar, updated.serialize()) + }, + ) + } +} diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt index 4d748fc28..c2dc068bb 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt @@ -1,53 +1,19 @@ package com.flipcash.app.scanner.internal.ui.components -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.layoutId -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.bill.BillState +import com.flipcash.app.core.navigation.NavBarButton +import com.flipcash.app.core.navigation.NavBarConfig +import com.flipcash.app.core.ui.NavigationBar +import com.flipcash.app.core.ui.NavigationBarState +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.scanner.internal.ScannerDecorItem import com.flipcash.app.session.SessionState -import com.flipcash.features.scanner.R -import com.getcode.theme.CodeTheme -import com.getcode.theme.DesignSystem -import com.getcode.theme.xxl -import com.getcode.ui.components.Badge -import com.getcode.ui.components.Pill -import com.getcode.ui.core.unboundedClickable -import com.getcode.ui.utils.heightOrZero -import com.getcode.ui.utils.widthOrZero @Composable internal fun ScannerNavigationBar( @@ -57,173 +23,30 @@ internal fun ScannerNavigationBar( isPaused: Boolean = false, onAction: (ScannerDecorItem) -> Unit = { } ) { - Row( - modifier = Modifier - .fillMaxWidth() - .then(modifier), - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.SpaceAround, - ) { - BottomBarAction( - modifier = Modifier.weight(1f), - label = stringResource(R.string.action_give), - painter = painterResource(R.drawable.ic_cash_bill), - badgeCount = 0, - onClick = { onAction(ScannerDecorItem.Give) } - ) - - BottomBarAction( - modifier = Modifier.weight(1f), - label = stringResource(R.string.action_wallet), - painter = painterResource(R.drawable.ic_flipcash_balance), - badgeCount = state.notificationUnreadCount, - onClick = { onAction(ScannerDecorItem.Wallet) }, - toast = { - AnimatedVisibility( - visible = billState.showToast && billState.toast != null, - enter = slideInVertically(animationSpec = tween(600), initialOffsetY = { it }) + - fadeIn(animationSpec = tween(500, 100)), - exit = if (!isPaused) - slideOutVertically(animationSpec = tween(600), targetOffsetY = { it }) + - fadeOut(animationSpec = tween(500, 100)) - else fadeOut(animationSpec = tween(0)), - ) { - val toast by remember(billState.toast) { - derivedStateOf { billState.toast } - } - Pill( - text = toast?.formattedAmount.orEmpty(), - textStyle = CodeTheme.typography.textSmall.copy( - fontWeight = FontWeight.Bold - ), - shape = CodeTheme.shapes.xxl, - ) - } - } - ) - - BottomBarAction( - modifier = Modifier.weight(1f), - label = stringResource(R.string.action_discover), - painter = painterResource(R.drawable.ic_coins), - badgeCount = 0, - onClick = { onAction(ScannerDecorItem.Discover) } - ) - } -} - -@Composable -private fun BottomBarAction( - painter: Painter, - label: String, - modifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues( - vertical = CodeTheme.dimens.grid.x2 - ), - imageSize: Dp = CodeTheme.dimens.staticGrid.x10, - toast: @Composable () -> Unit = { }, - badgeCount: Int = 0, - onClick: (() -> Unit)?, -) { - Column( - modifier = modifier.width(IntrinsicSize.Max), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - toast() - BottomBarAction( - label = label, - contentPadding = contentPadding, - painter = painter, - imageSize = imageSize, - badge = { - Badge( - modifier = Modifier.padding(top = 6.dp, end = 1.dp), - count = badgeCount, - color = CodeTheme.colors.indicator, - enterTransition = scaleIn( - animationSpec = tween( - durationMillis = 300, - delayMillis = 1000 - ) - ) + fadeIn() - ) - }, - onClick = onClick - ) + val featureFlags = LocalFeatureFlags.current + val navBarConfigString by featureFlags + .getOption(FeatureFlag.NavBar) + .collectAsStateWithLifecycle() + val config = remember(navBarConfigString) { + NavBarConfig.deserialize(navBarConfigString) } -} -@Composable -private fun BottomBarAction( - modifier: Modifier = Modifier, - label: String, - contentPadding: PaddingValues = PaddingValues( - vertical = CodeTheme.dimens.grid.x2 - ), - painter: Painter, - iconColor: Color = Color.White, - textColor: Color = Color.White, - imageSize: Dp = CodeTheme.dimens.staticGrid.x10, - badge: @Composable () -> Unit = { }, - onClick: (() -> Unit)?, -) { - Layout( + NavigationBar( modifier = modifier, - content = { - Column( - modifier = Modifier - .unboundedClickable( - enabled = onClick != null, - rippleRadius = imageSize - ) { onClick?.invoke() } - .layoutId("action"), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image( - modifier = Modifier - .padding(contentPadding) - .size(imageSize), - painter = painter, - colorFilter = ColorFilter.tint(iconColor), - contentDescription = null, - ) - Text( - text = label, - style = CodeTheme.typography.textSmall, - color = textColor - ) - } - - Box(modifier = Modifier.layoutId("badge")) { - badge() + config = config, + state = NavigationBarState( + notificationUnreadCount = state.notificationUnreadCount, + showToast = billState.showToast && billState.toast != null, + toastText = billState.toast?.formattedAmount, + isPaused = isPaused, + ), + onButtonClick = { button -> + val item = when (button) { + NavBarButton.Give -> ScannerDecorItem.Give + NavBarButton.Wallet -> ScannerDecorItem.Wallet + NavBarButton.Discover -> ScannerDecorItem.Discover } - } - ) { measurables, incomingConstraints -> - val constraints = incomingConstraints.copy(minWidth = 0, minHeight = 0) - val actionPlaceable = - measurables.find { it.layoutId == "action" }?.measure(constraints) - val badgePlaceable = - measurables.find { it.layoutId == "badge" }?.measure(constraints) - - val maxWidth = widthOrZero(actionPlaceable) - val maxHeight = heightOrZero(actionPlaceable) - layout( - width = maxWidth, - height = maxHeight, - ) { - actionPlaceable?.placeRelative(0, 0) - badgePlaceable?.placeRelative( - x = maxWidth - widthOrZero(badgePlaceable), - y = -(heightOrZero(badgePlaceable) / 3) - ) - } - } + onAction(item) + }, + ) } - -@Preview -@Composable -private fun PreviewNavBar() { - DesignSystem { - ScannerNavigationBar { } - } -} \ No newline at end of file diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index ae16a268f..0e8642630 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -1,6 +1,7 @@ package com.flipcash.app.featureflags import com.flipcash.app.featureflags.model.BackgroundResetTimeout +import com.flipcash.app.core.navigation.NavBarConfig import com.flipcash.app.ksp.annotations.FeatureFlagMarker import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes @@ -169,6 +170,16 @@ sealed interface FeatureFlag { .map { FlagOption(it.name, it.label, isDisabled = it.duration == null) } } + @FeatureFlagMarker + data object NavBar : FeatureFlag { + override val key: String = "nav_bar_config" + override val default: NavBarConfig = NavBarConfig.Default + override val launched: Boolean = false + override val visible: Boolean = false + override val persistLogOut: Boolean = false + override val defaultOption: String get() = default.serialize() + } + companion object { val entries: List> get() = FeatureFlagEntries.entries @@ -198,6 +209,7 @@ val FeatureFlag<*>.title: String FeatureFlag.BillTextures -> "Bill Textures" FeatureFlag.DepositUsdc -> "Deposit USDC" FeatureFlag.BackgroundReset -> "Background Reset" + FeatureFlag.NavBar -> "Navigation Bar" } val FeatureFlag<*>.message: String @@ -218,6 +230,7 @@ val FeatureFlag<*>.message: String FeatureFlag.BillTextures -> "When enabled, you'll gain the ability to select textures for bills during currency creation" FeatureFlag.DepositUsdc -> "When enabled, you'll gain the ability to deposit USDC directly from any external wallet app instead of purchasing a currency first and sell" FeatureFlag.BackgroundReset -> "Automatically returns the app to the camera screen after a period of inactivity with the app in the background" + FeatureFlag.NavBar -> "Customize the order and labels of navigation bar buttons" } diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt index 1175c39d5..866156a22 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt @@ -16,6 +16,7 @@ enum class NavMetadataKeys(val key: String, ) { IsNonDismissable("non_dismissable"), IsNonDraggable("non_draggable"), IsSheet("sheet"), + IsWrapContentSheet("sheet_wrap_content"), IsSolitarySheet("sheet_solitary"), NavResultKey("navresult_key"), } @@ -58,6 +59,7 @@ fun KClass<*>.metadata(): Map { return mapOf( NavMetadataKeys.IsSheet.key to Sheet::class.java.isAssignableFrom(this.java), + NavMetadataKeys.IsWrapContentSheet.key to WrapContentSheet::class.java.isAssignableFrom(this.java), NavMetadataKeys.IsSolitarySheet.key to SolitarySheet::class.java.isAssignableFrom(this.java), NavMetadataKeys.IsNonDismissable.key to NonDismissableRoute::class.java.isAssignableFrom(this.java), NavMetadataKeys.IsNonDraggable.key to NonDraggableRoute::class.java.isAssignableFrom(this.java), diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt index 8730ba211..1215898fe 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt @@ -3,6 +3,7 @@ package com.getcode.navigation import androidx.navigation3.runtime.NavKey interface Sheet: NavKey +interface WrapContentSheet: NavKey interface NonDismissableRoute: NavKey interface NonDraggableRoute: NavKey interface SolitarySheet: NavKey diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt index 0191fbc69..b859c30dc 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt @@ -224,10 +224,18 @@ internal class ModalBottomSheetScene @OptIn(ExperimentalMaterial3Api::c ) } } + val isWrapContent = + metadata[NavMetadataKeys.IsWrapContentSheet.key] as? Boolean ?: false Box( modifier = Modifier .fillMaxWidth() - .fillMaxHeight(CodeTheme.dimens.modalHeightRatio) + .then( + if (!isWrapContent) { + Modifier.fillMaxHeight(CodeTheme.dimens.modalHeightRatio) + } else { + Modifier + } + ) ) { SharedTransitionLayout { CompositionLocalProvider( @@ -237,7 +245,9 @@ internal class ModalBottomSheetScene @OptIn(ExperimentalMaterial3Api::c ) { entry.Content() } - ScrimOverlay(scrim) + if (!isWrapContent) { + ScrimOverlay(scrim) + } } } }