From bbb834afc27f89bd53baf7f26e44ed94ba15f91c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 23 May 2026 19:53:39 -0400 Subject: [PATCH 1/3] feat(nav): add WrapContentSheet support to navigation Add WrapContentSheet interface, IsWrapContentSheet metadata key, and conditional fillMaxHeight/ScrimOverlay handling for wrap-content sheets. Co-Authored-By: Claude Opus 4.6 --- .../kotlin/com/getcode/navigation/NavMetadata.kt | 2 ++ .../main/kotlin/com/getcode/navigation/Types.kt | 1 + .../scenes/ModalBottomSheetSceneStrategy.kt | 14 ++++++++++++-- 3 files changed, 15 insertions(+), 2 deletions(-) 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) + } } } } From c80b95c60761125c6245d8b2b5f58f324d9dae84 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 23 May 2026 19:53:49 -0400 Subject: [PATCH 2/3] feat(core): add NavBarConfig model and shared NavigationBar composable Extract NavBarButton, GiveButtonLabel, NavBarConfig models and shared NavigationBar composable from scanner. Add LongPressDraggable utility and NavBar feature flag. Refactor ScannerNavigationBar to use shared NavigationBar. Co-Authored-By: Claude Opus 4.6 --- .../app/core/navigation/GiveButtonLabel.kt | 9 + .../app/core/navigation/NavBarButton.kt | 12 + .../app/core/navigation/NavBarConfig.kt | 27 ++ .../app/core/ui/LongPressDraggable.kt | 128 +++++++++ .../com/flipcash/app/core/ui/NavigationBar.kt | 246 ++++++++++++++++++ .../ui/components/ScannerNavigationBar.kt | 235 +++-------------- .../flipcash/app/featureflags/FeatureFlag.kt | 13 + 7 files changed, 464 insertions(+), 206 deletions(-) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/GiveButtonLabel.kt create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarButton.kt create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarConfig.kt create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/LongPressDraggable.kt create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt 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/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" } From 29e8805d68cbae4c4befaa096e11350cd7f34932 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 23 May 2026 19:54:00 -0400 Subject: [PATCH 3/3] feat(lab): add nav bar settings sheet with drag-to-reorder Add NavBarSettingsScreen and content with drag-to-reorder support. Register NavBarSettings route, add string resources, wire entry in Labs "Home Screen" section, and register screen in AppScreenContent. Co-Authored-By: Claude Opus 4.6 --- .../ui/navigation/AppScreenContent.kt | 3 +- .../kotlin/com/flipcash/app/core/AppRoute.kt | 2 + .../res/drawable/ic_bottom_navigation.xml | 24 ++++++ .../core/src/main/res/values/strings.xml | 5 ++ .../flipcash/app/lab/NavBarSettingsScreen.kt | 31 +++++++ .../app/lab/internal/LabsScreenContent.kt | 12 +++ .../app/lab/internal/NavBarSettingsContent.kt | 86 +++++++++++++++++++ 7 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_bottom_navigation.xml create mode 100644 apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/NavBarSettingsScreen.kt create mode 100644 apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/NavBarSettingsContent.kt 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/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()) + }, + ) + } +}