Skip to content

Commit 3ad4a88

Browse files
committed
fix: in-sheet navigation fix
1 parent cc74a6e commit 3ad4a88

File tree

10 files changed

+213
-118
lines changed

10 files changed

+213
-118
lines changed

AGENTS.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,54 @@ suspend fun getData(): Result<Data> = withContext(Dispatchers.IO) {
151151
}
152152
```
153153

154-
### Rules
154+
### Sheet Navigation Flows
155+
156+
`SheetSceneStrategy.calculateScene()` finds the FIRST entry with sheet metadata (the sheet root) and renders ALL subsequent entries within that same sheet container. Swiping to dismiss closes the entire sheet flow.
157+
158+
**Pattern:**
159+
160+
- Only ROOT routes need `metadata = SheetSceneStrategy.sheet()` (e.g., `Pin.Prompt`, `Receive.Qr`)
161+
- Sub-routes have NO metadata - they navigate within the parent sheet container
162+
163+
**Flows:**
164+
165+
1. **Pin Flow** (root: `Pin.Prompt`)
166+
167+
- Pin.Prompt → Pin.Choose → Pin.Confirm → Pin.Biometrics → Pin.Result
168+
169+
2. **Backup Flow** (root: `Backup.Intro`)
170+
171+
- Backup.Intro → ShowMnemonic → ShowPassphrase → ConfirmMnemonic → ConfirmPassphrase → Warning → Success → MultipleDevices → Metadata
172+
173+
3. **Send Flow** (root: `Send.Recipient`)
174+
175+
- Send.Recipient → Address → Amount → QrScanner → CoinSelection → FeeRate → FeeCustom → Confirm → Success/Error
176+
- Also: WithdrawConfirm, WithdrawError, Support, AddTag, PinCheck, QuickPay
177+
178+
4. **Receive Flow** (root: `Receive.Qr`)
179+
180+
- Receive.Qr → EditInvoice → AddTag
181+
- Receive.Qr → Amount → Confirm → Liquidity
182+
- Receive.Qr → GeoBlock
183+
- Also: ConfirmInbound, LiquidityAdditional
184+
185+
5. **Gift Flow** (root: `Gift.Loading`)
186+
187+
- Gift.Loading → Used/UsedUp/Error/Success
188+
189+
**Standalone Sheets** (single-screen, each has sheet metadata):
190+
191+
- Activity.DateRangeSelectorSheet
192+
- Activity.TagSelectorSheet
193+
- Sheet.LnurlAuth
194+
- Sheet.ForceTransfer
195+
- Sheet.Update
196+
- Sheet.Backup
197+
- Sheet.Notifications
198+
- Sheet.QuickPay
199+
- Sheet.HighBalance
200+
201+
## Rules
155202

156203
- USE coding rules from `.cursor/default.rules.mdc`
157204
- ALWAYS run `./gradlew compileDevDebugKotlin` after code changes to verify code compiles

app/src/main/java/to/bitkit/repositories/WalletRepo.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ class WalletRepo @Inject constructor(
110110
suspend fun refreshBip21(): Result<Unit> = withContext(bgDispatcher) {
111111
Logger.debug("Refreshing bip21", context = TAG)
112112

113+
// Preserve current amount/description before clearing
114+
val currentAmount = _walletState.value.bip21AmountSats
115+
val currentDescription = _walletState.value.bip21Description
116+
113117
// Get old payment ID and tags before refreshing (which may change payment ID)
114118
val oldPaymentId = paymentId()
115119
val tagsToMigrate = if (oldPaymentId != null && oldPaymentId.isNotEmpty()) {
@@ -123,7 +127,7 @@ class WalletRepo @Inject constructor(
123127

124128
clearBip21State(clearTags = false)
125129
refreshAddressIfNeeded()
126-
updateBip21Invoice()
130+
updateBip21Invoice(amountSats = currentAmount, description = currentDescription)
127131

128132
val newPaymentId = paymentId()
129133
val newBip21Url = _walletState.value.bip21

app/src/main/java/to/bitkit/ui/ContentView.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import androidx.compose.runtime.CompositionLocalProvider
1212
import androidx.compose.runtime.DisposableEffect
1313
import androidx.compose.runtime.LaunchedEffect
1414
import androidx.compose.runtime.collectAsState
15+
import androidx.compose.runtime.derivedStateOf
1516
import androidx.compose.runtime.getValue
1617
import androidx.compose.runtime.mutableIntStateOf
1718
import androidx.compose.runtime.mutableStateOf
@@ -304,15 +305,23 @@ fun ContentView(
304305
}
305306
)
306307

308+
// Use derivedStateOf to ensure reactive observation of backStack changes
309+
val showTabBar by remember {
310+
derivedStateOf { navigator.shouldShowTabBar() }
311+
}
312+
307313
AnimatedVisibility(
308-
visible = navigator.shouldShowTabBar(),
314+
visible = showTabBar,
309315
enter = slideInVertically { it },
310316
exit = slideOutVertically { it },
311317
modifier = Modifier.align(Alignment.BottomCenter),
312318
) {
313319
TabBar(
314320
onSendClick = { navigator.navigate(Routes.Send.Recipient) },
315-
onReceiveClick = { navigator.navigate(Routes.Receive.Qr) },
321+
onReceiveClick = {
322+
walletViewModel.resetReceiveState()
323+
navigator.navigate(Routes.Receive.Qr)
324+
},
316325
onScanClick = { navigator.navigate(Routes.QrScanner) },
317326
)
318327
}
Lines changed: 91 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
package to.bitkit.ui.nav
22

3+
import androidx.compose.animation.AnimatedContent
4+
import androidx.compose.animation.core.FastOutSlowInEasing
5+
import androidx.compose.animation.core.tween
6+
import androidx.compose.animation.fadeIn
7+
import androidx.compose.animation.fadeOut
8+
import androidx.compose.animation.slideInHorizontally
9+
import androidx.compose.animation.slideOutHorizontally
10+
import androidx.compose.animation.togetherWith
311
import androidx.compose.runtime.Composable
412
import androidx.navigation3.runtime.NavEntry
513
import androidx.navigation3.scene.OverlayScene
@@ -12,19 +20,39 @@ import to.bitkit.ui.components.SheetSize
1220
class SheetSceneStrategy<T : Any> : SceneStrategy<T> {
1321

1422
override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? {
15-
val lastEntry = entries.lastOrNull()
16-
val sheetProperties = lastEntry?.metadata?.get(KEY_SHEET) as? SheetProperties
17-
return sheetProperties?.let { props ->
18-
@Suppress("UNCHECKED_CAST")
19-
SheetScene(
20-
key = lastEntry.contentKey as T,
21-
previousEntries = entries.dropLast(1),
22-
overlaidEntries = entries.dropLast(1),
23-
entry = lastEntry,
24-
sheetSize = props.size,
25-
onBack = onBack,
26-
)
23+
val lastEntry = entries.lastOrNull() ?: return null
24+
25+
// Find the sheet root (first entry with sheet metadata)
26+
val sheetRootIndex = entries.indexOfFirst {
27+
it.metadata?.get(KEY_SHEET) != null
2728
}
29+
30+
// No sheet root found - not a sheet flow
31+
if (sheetRootIndex < 0) return null
32+
33+
val sheetRootEntry = entries[sheetRootIndex]
34+
val sheetProps = sheetRootEntry.metadata?.get(KEY_SHEET) as? SheetProperties ?: return null
35+
36+
// Entries before the sheet root (to be overlaid)
37+
val entriesBeforeSheet = entries.take(sheetRootIndex)
38+
39+
// Number of entries in the sheet flow (for dismiss behavior)
40+
val sheetEntryCount = entries.size - sheetRootIndex
41+
42+
// Current entry's index within the sheet flow (for animation direction)
43+
val currentEntryIndex = entries.size - 1
44+
45+
@Suppress("UNCHECKED_CAST")
46+
return SheetScene(
47+
key = lastEntry.contentKey as T,
48+
previousEntries = entriesBeforeSheet,
49+
overlaidEntries = entriesBeforeSheet,
50+
entry = lastEntry,
51+
sheetSize = sheetProps.size,
52+
onBack = onBack,
53+
sheetEntryCount = sheetEntryCount,
54+
entryIndex = currentEntryIndex,
55+
)
2856
}
2957

3058
companion object {
@@ -40,23 +68,72 @@ data class SheetProperties(
4068
val size: SheetSize = SheetSize.LARGE,
4169
)
4270

71+
private const val MS_ANIM_DURATION = 300
72+
73+
private data class IndexedEntry<T : Any>(
74+
val index: Int,
75+
val entry: NavEntry<T>,
76+
)
77+
78+
@Suppress("LongParameterList")
4379
internal class SheetScene<T : Any>(
4480
override val key: T,
4581
override val previousEntries: List<NavEntry<T>>,
4682
override val overlaidEntries: List<NavEntry<T>>,
4783
private val entry: NavEntry<T>,
4884
private val sheetSize: SheetSize,
4985
private val onBack: () -> Unit,
86+
private val sheetEntryCount: Int,
87+
private val entryIndex: Int,
5088
) : OverlayScene<T> {
5189

5290
override val entries: List<NavEntry<T>> = listOf(entry)
5391

5492
override val content: @Composable (() -> Unit) = {
5593
SheetHost(
5694
sheetSize = sheetSize,
57-
onDismiss = onBack,
95+
onDismiss = {
96+
// Pop all entries in the sheet flow to dismiss entire sheet
97+
repeat(sheetEntryCount) { onBack() }
98+
},
5899
) {
59-
entry.Content()
100+
AnimatedContent(
101+
targetState = IndexedEntry(entryIndex, entry),
102+
transitionSpec = {
103+
val isForward = targetState.index > initialState.index
104+
if (isForward) {
105+
// Forward navigation: slide in from right, slide out to left
106+
slideInHorizontally(
107+
initialOffsetX = { it },
108+
animationSpec = tween(MS_ANIM_DURATION, easing = FastOutSlowInEasing)
109+
) + fadeIn(
110+
animationSpec = tween(MS_ANIM_DURATION, easing = FastOutSlowInEasing),
111+
initialAlpha = 0.8f
112+
) togetherWith slideOutHorizontally(
113+
targetOffsetX = { -it / 3 },
114+
animationSpec = tween(MS_ANIM_DURATION, easing = FastOutSlowInEasing)
115+
) + fadeOut(
116+
animationSpec = tween(MS_ANIM_DURATION, easing = FastOutSlowInEasing),
117+
targetAlpha = 0.8f
118+
)
119+
} else {
120+
// Backward navigation: slide in from left, slide out to right
121+
slideInHorizontally(
122+
initialOffsetX = { -it / 3 },
123+
animationSpec = tween(MS_ANIM_DURATION, easing = FastOutSlowInEasing)
124+
) + fadeIn(
125+
animationSpec = tween(MS_ANIM_DURATION, easing = FastOutSlowInEasing),
126+
initialAlpha = 0.8f
127+
) togetherWith slideOutHorizontally(
128+
targetOffsetX = { it },
129+
animationSpec = tween(MS_ANIM_DURATION, easing = FastOutSlowInEasing)
130+
)
131+
}
132+
},
133+
label = "SheetContentTransition",
134+
) { indexedEntry ->
135+
indexedEntry.entry.Content()
136+
}
60137
}
61138
}
62139
}

app/src/main/java/to/bitkit/ui/nav/entries/HomeEntries.kt

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ fun EntryProviderScope<NavKey>.homeEntries(
5555
entry<Routes.Savings> {
5656
SavingsEntry(
5757
navigator = navigator,
58+
walletViewModel = walletViewModel,
5859
appViewModel = appViewModel,
5960
activityListViewModel = activityListViewModel,
6061
settingsViewModel = settingsViewModel,
@@ -80,7 +81,10 @@ fun EntryProviderScope<NavKey>.homeEntries(
8081
onActivityItemClick = { navigator.navigate(Routes.Activity.Detail(it)) },
8182
onTagClick = { navigator.navigate(Routes.Activity.TagSelectorSheet) },
8283
onDateRangeClick = { navigator.navigate(Routes.Activity.DateRangeSelectorSheet) },
83-
onEmptyActivityRowClick = { navigator.navigate(Routes.Receive.Qr) },
84+
onEmptyActivityRowClick = {
85+
walletViewModel.resetReceiveState()
86+
navigator.navigate(Routes.Receive.Qr)
87+
},
8488
)
8589
}
8690

@@ -130,6 +134,7 @@ fun EntryProviderScope<NavKey>.homeEntries(
130134
@Composable
131135
private fun SavingsEntry(
132136
navigator: Navigator,
137+
walletViewModel: WalletViewModel,
133138
appViewModel: AppViewModel,
134139
activityListViewModel: ActivityListViewModel,
135140
settingsViewModel: SettingsViewModel,
@@ -143,7 +148,10 @@ private fun SavingsEntry(
143148
onchainActivities = onchainActivities.orEmpty(),
144149
onAllActivityButtonClick = { navigator.navigate(Routes.Activity.All) },
145150
onActivityItemClick = { navigator.navigate(Routes.Activity.Detail(it)) },
146-
onEmptyActivityRowClick = { navigator.navigate(Routes.Receive.Qr) },
151+
onEmptyActivityRowClick = {
152+
walletViewModel.resetReceiveState()
153+
navigator.navigate(Routes.Receive.Qr)
154+
},
147155
onTransferToSpendingClick = {
148156
if (!hasSeenSpendingIntro) {
149157
navigator.navigate(Routes.Transfer.ToSpending.Intro)
@@ -171,7 +179,10 @@ private fun SpendingEntry(
171179
lightningActivities = lightningActivities.orEmpty(),
172180
onAllActivityButtonClick = { navigator.navigate(Routes.Activity.All) },
173181
onActivityItemClick = { navigator.navigate(Routes.Activity.Detail(it)) },
174-
onEmptyActivityRowClick = { navigator.navigate(Routes.Receive.Qr) },
182+
onEmptyActivityRowClick = {
183+
walletViewModel.resetReceiveState()
184+
navigator.navigate(Routes.Receive.Qr)
185+
},
175186
onTransferToSavingsClick = {
176187
if (!hasSeenSavingsIntro) {
177188
navigator.navigate(Routes.Transfer.ToSavings.Intro)

0 commit comments

Comments
 (0)