Skip to content

Commit 807b939

Browse files
bmc08gtclaude
andauthored
feat(home): add nav bar settings sheet with drag-to-reorder (#767)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0b4f7a7 commit 807b939

17 files changed

Lines changed: 641 additions & 209 deletions

File tree

apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ import com.flipcash.app.contact.verification.VerificationFlowScreen
2828
import com.flipcash.app.currencycreator.CurrencyCreatorFlowScreen
2929
import com.flipcash.app.core.AppRoute
3030
import com.flipcash.app.currency.RegionSelectionScreen
31-
import com.flipcash.app.deposit.DepositDestinationScreen
3231
import com.flipcash.app.deposit.DepositFlowScreen
3332
import com.flipcash.app.discovery.TokenDiscoveryScreen
3433
import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator
3534
import com.flipcash.app.lab.LabsScreen
35+
import com.flipcash.app.lab.NavBarSettingsScreen
3636
import com.flipcash.app.lab.StandaloneLabsScreen
3737
import com.flipcash.app.login.accesskey.AccessKeyScreen
3838
import com.flipcash.app.login.accesskey.PhotoAccessKeyScreen
@@ -125,6 +125,7 @@ fun appEntryProvider(
125125
// Menu
126126
annotatedEntry<AppRoute.Menu.AppSettings> { AppSettingsScreen() }
127127
annotatedEntry<AppRoute.Menu.Lab> { LabsScreen() }
128+
annotatedEntry<AppRoute.Menu.NavBarSettings> { NavBarSettingsScreen() }
128129
annotatedEntry<AppRoute.Menu.MyAccount> { MyAccountScreen() }
129130
annotatedEntry<AppRoute.Menu.BackupKey> { BackupKeyScreen() }
130131
annotatedEntry<AppRoute.Menu.AdvancedFeatures> { AdvancedFeaturesScreen() }

apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ sealed interface AppRoute : NavKey, Parcelable {
193193
data object DeviceLogs : Menu
194194
@Serializable
195195
data object Lab : Menu
196+
@Serializable
197+
data object NavBarSettings : Menu, com.getcode.navigation.Sheet, com.getcode.navigation.WrapContentSheet
196198
}
197199

198200
@Serializable
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.flipcash.app.core.navigation
2+
3+
import androidx.annotation.StringRes
4+
import com.flipcash.core.R
5+
6+
enum class GiveButtonLabel(@StringRes val labelRes: Int) {
7+
Give(R.string.action_give),
8+
Cash(R.string.action_cash),
9+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.flipcash.app.core.navigation
2+
3+
enum class NavBarButton {
4+
Give,
5+
Wallet,
6+
Discover,
7+
;
8+
9+
companion object {
10+
val defaultOrder = listOf(Give, Wallet, Discover)
11+
}
12+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.flipcash.app.core.navigation
2+
3+
data class NavBarConfig(
4+
val order: List<NavBarButton> = NavBarButton.defaultOrder,
5+
val giveButtonLabel: GiveButtonLabel = GiveButtonLabel.Give,
6+
) {
7+
fun serialize(): String =
8+
"${order.joinToString(",") { it.name }}|${giveButtonLabel.name}"
9+
10+
companion object {
11+
val Default = NavBarConfig()
12+
13+
fun deserialize(value: String): NavBarConfig {
14+
if (value.isBlank()) return Default
15+
val parts = value.split("|")
16+
val order = parts.getOrNull(0)
17+
?.split(",")
18+
?.mapNotNull { runCatching { NavBarButton.valueOf(it) }.getOrNull() }
19+
?.ifEmpty { NavBarButton.defaultOrder }
20+
?: NavBarButton.defaultOrder
21+
val label = parts.getOrNull(1)
22+
?.let { runCatching { GiveButtonLabel.valueOf(it) }.getOrNull() }
23+
?: GiveButtonLabel.Give
24+
return NavBarConfig(order, label)
25+
}
26+
}
27+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package com.flipcash.app.core.ui
2+
3+
import androidx.compose.animation.core.Spring
4+
import androidx.compose.animation.core.animateIntAsState
5+
import androidx.compose.animation.core.spring
6+
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
7+
import androidx.compose.foundation.layout.offset
8+
import androidx.compose.runtime.Composable
9+
import androidx.compose.runtime.derivedStateOf
10+
import androidx.compose.runtime.getValue
11+
import androidx.compose.runtime.mutableFloatStateOf
12+
import androidx.compose.runtime.mutableIntStateOf
13+
import androidx.compose.runtime.remember
14+
import androidx.compose.ui.Modifier
15+
import androidx.compose.ui.input.pointer.pointerInput
16+
import androidx.compose.ui.layout.onSizeChanged
17+
import androidx.compose.ui.unit.IntOffset
18+
import androidx.compose.ui.zIndex
19+
import kotlin.math.roundToInt
20+
21+
class LongPressDraggableState internal constructor(
22+
internal val itemCount: Int,
23+
) {
24+
internal val draggingIndex = mutableIntStateOf(-1)
25+
internal val dragOffsetX = mutableFloatStateOf(0f)
26+
internal val itemWidthPx = mutableIntStateOf(0)
27+
internal var onReorder: (from: Int, to: Int) -> Unit = { _, _ -> }
28+
}
29+
30+
/**
31+
* @param key When this key changes the drag state resets. Pass the current order
32+
* so that after a reorder propagates, the state clears atomically
33+
* with the new layout positions.
34+
*/
35+
@Composable
36+
fun rememberLongPressDraggableState(
37+
itemCount: Int,
38+
key: Any? = null,
39+
onReorder: (from: Int, to: Int) -> Unit,
40+
): LongPressDraggableState {
41+
val state = remember(itemCount, key) { LongPressDraggableState(itemCount) }
42+
state.onReorder = onReorder
43+
return state
44+
}
45+
46+
@Composable
47+
fun Modifier.longPressDraggable(
48+
state: LongPressDraggableState,
49+
index: Int,
50+
): Modifier {
51+
val displacement by remember(state, index) {
52+
derivedStateOf {
53+
val currentDragging = state.draggingIndex.intValue
54+
if (currentDragging == -1 || currentDragging == index) {
55+
0
56+
} else {
57+
val w = state.itemWidthPx.intValue
58+
if (w <= 0) 0
59+
else {
60+
val draggedVisualSlot = (currentDragging +
61+
(state.dragOffsetX.floatValue / w).roundToInt())
62+
.coerceIn(0, state.itemCount - 1)
63+
when {
64+
currentDragging < draggedVisualSlot &&
65+
index in (currentDragging + 1)..draggedVisualSlot -> -w
66+
currentDragging > draggedVisualSlot &&
67+
index in draggedVisualSlot until currentDragging -> w
68+
else -> 0
69+
}
70+
}
71+
}
72+
}
73+
}
74+
val animatedDisplacement by animateIntAsState(
75+
targetValue = displacement,
76+
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
77+
)
78+
79+
return this
80+
.zIndex(if (state.draggingIndex.intValue == index) 1f else 0f)
81+
.offset {
82+
val currentDragging = state.draggingIndex.intValue
83+
val dx = when {
84+
// Dragged item follows finger directly
85+
currentDragging == index -> state.dragOffsetX.floatValue.roundToInt()
86+
// Non-dragged items animate during an active drag
87+
currentDragging != -1 -> animatedDisplacement
88+
// No drag active — snap to natural position
89+
else -> 0
90+
}
91+
IntOffset(dx, 0)
92+
}
93+
.onSizeChanged { state.itemWidthPx.intValue = it.width }
94+
.pointerInput(state) {
95+
detectDragGesturesAfterLongPress(
96+
onDragStart = {
97+
state.draggingIndex.intValue = index
98+
state.dragOffsetX.floatValue = 0f
99+
},
100+
onDrag = { change, dragAmount ->
101+
change.consume()
102+
state.dragOffsetX.floatValue += dragAmount.x
103+
},
104+
onDragEnd = {
105+
val w = state.itemWidthPx.intValue
106+
if (w > 0) {
107+
val from = state.draggingIndex.intValue
108+
val to = (from + (state.dragOffsetX.floatValue / w).roundToInt())
109+
.coerceIn(0, state.itemCount - 1)
110+
if (from != to) {
111+
// Snap offset to exact target so item stays visually
112+
// in place until the reorder propagates and the state
113+
// resets via key change.
114+
state.dragOffsetX.floatValue = (to - from).toFloat() * w
115+
state.onReorder(from, to)
116+
return@detectDragGesturesAfterLongPress
117+
}
118+
}
119+
state.draggingIndex.intValue = -1
120+
state.dragOffsetX.floatValue = 0f
121+
},
122+
onDragCancel = {
123+
state.draggingIndex.intValue = -1
124+
state.dragOffsetX.floatValue = 0f
125+
},
126+
)
127+
}
128+
}

0 commit comments

Comments
 (0)