Skip to content

Commit c80b95c

Browse files
bmc08gtclaude
andcommitted
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>
1 parent bbb834a commit c80b95c

7 files changed

Lines changed: 464 additions & 206 deletions

File tree

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)