Skip to content

Commit 4126e1d

Browse files
committed
Merge branch 'fix/transfer-spending-max-lsp-cap' of github.com:synonymdev/bitkit-ios into fix/transfer-spending-max-lsp-cap
2 parents c6d3a64 + a41ac50 commit 4126e1d

9 files changed

Lines changed: 162 additions & 19 deletions

File tree

Bitkit/Components/Widgets/Suggestions.swift

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,10 @@ struct Suggestions: View {
164164
/// When true, show a fixed set of static cards and ignore taps (e.g. widget preview).
165165
var isPreview: Bool = false
166166

167+
/// When editing the home grid, keep the widget visible (and reorderable/removable) by falling
168+
/// back to the static preview set when there are no live cards to show.
169+
var isEditing: Bool = false
170+
167171
var previewCardIds: [String]?
168172

169173
static let previewSheetCardIds = ["backupSeedPhrase", "pin", "transferToSpending", "support"]
@@ -250,8 +254,30 @@ struct Suggestions: View {
250254
)
251255
}
252256

257+
private var isEditingFallback: Bool {
258+
isEditing && !isPreview && visibleCards.isEmpty
259+
}
260+
261+
private var cardsToShow: [SuggestionCardData] {
262+
guard isEditingFallback else { return visibleCards }
263+
return Self.visibleCards(
264+
wallet: wallet,
265+
app: app,
266+
settings: settings,
267+
suggestionsManager: suggestionsManager,
268+
pubkyProfile: pubkyProfile,
269+
isPaykitUIEnabled: isPaykitUIActive,
270+
isPreview: true,
271+
previewCardIds: Self.previewSheetCardIds
272+
)
273+
}
274+
275+
private var renderStatic: Bool {
276+
isPreview || isEditingFallback
277+
}
278+
253279
var body: some View {
254-
if visibleCards.isEmpty {
280+
if cardsToShow.isEmpty {
255281
EmptyView()
256282
} else {
257283
LazyVGrid(
@@ -261,25 +287,25 @@ struct Suggestions: View {
261287
],
262288
spacing: 16
263289
) {
264-
ForEach(visibleCards) { card in
290+
ForEach(cardsToShow) { card in
265291
SuggestionCard(
266292
title: card.title,
267293
description: card.description,
268294
imageName: card.imageName,
269295
accentColor: card.color,
270-
onTap: { if !isPreview { onItemTap(card) } },
296+
onTap: { if !renderStatic { onItemTap(card) } },
271297
onDismiss: { dismissCard(card) }
272298
)
273299
.background {
274-
if isPreview {
300+
if renderStatic {
275301
RoundedRectangle(cornerRadius: 16).fill(Color.black)
276302
}
277303
}
278304
.accessibilityElement(children: .contain)
279305
.accessibilityIdentifier("Suggestion-\(card.accessibilityId)")
280306
}
281307
}
282-
.allowsHitTesting(!isPreview)
308+
.allowsHitTesting(!renderStatic)
283309
.sheet(isPresented: $showShareSheet) {
284310
ShareSheet(activityItems: [
285311
t(

Bitkit/Components/Widgets/SuggestionsWidget.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ struct SuggestionsWidget: View {
1313
hasBackground: false,
1414
onEditingEnd: onEditingEnd
1515
) {
16-
Suggestions(isPreview: isPreview)
16+
Suggestions(isPreview: isPreview, isEditing: isEditing)
1717
}
1818
}
1919
}

Bitkit/ViewModels/AppViewModel.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,17 @@ extension AppViewModel {
387387
return
388388
}
389389

390-
// Lightning insufficient for any reason (no channels, no capacity, etc).
390+
// Channels exist but none are usable (e.g. peer offline): still prefer
391+
// lightning. The send sheet shows the sync overlay and either proceeds
392+
// over lightning when the peer reconnects or falls back to onchain
393+
// after its timeout.
394+
if let channels = lightningService.channels, !channels.isEmpty, !channels.contains(where: \.isUsable) {
395+
handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice)
396+
return
397+
}
398+
399+
// Lightning insufficient for any other reason (no channels at all, or
400+
// usable channels without capacity).
391401
// Fall back to onchain and validate onchain balance immediately.
392402
let onchainBalance = lightningService.balances?.spendableOnchainBalanceSats ?? 0
393403
guard validateOnchainBalance(invoiceAmount: invoice.amountSatoshis, onchainBalance: onchainBalance) else {

Bitkit/Views/Offline/OfflineSheetScreen.swift

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,17 @@ private struct OfflineSheetOverlayModifier: ViewModifier {
2525
@EnvironmentObject private var network: NetworkMonitor
2626

2727
let title: String
28+
var forceShow = false
29+
30+
private var isShowing: Bool {
31+
!network.isConnected || forceShow
32+
}
2833

2934
func body(content: Content) -> some View {
3035
ZStack(alignment: .top) {
3136
content
3237

33-
if !network.isConnected {
38+
if isShowing {
3439
OfflineSheetScreen(title: title)
3540
.transition(.opacity)
3641

@@ -41,14 +46,15 @@ private struct OfflineSheetOverlayModifier: ViewModifier {
4146
.padding(.top, 12)
4247
}
4348
}
44-
.animation(.easeInOut(duration: 0.3), value: network.isConnected)
49+
.animation(.easeInOut(duration: 0.3), value: isShowing)
4550
}
4651
}
4752

4853
extension View {
49-
/// Overlays a `OfflineSheetScreen` when the device is offline.
54+
/// Overlays a `OfflineSheetScreen` when the device is offline, or whenever `forceShow` is true
55+
/// (e.g. connection issues beyond device connectivity, like an unreachable Lightning peer).
5056
/// The underlying content remains mounted so navigation state and inputs are preserved.
51-
func offlineSheetOverlay(title: String) -> some View {
52-
modifier(OfflineSheetOverlayModifier(title: title))
57+
func offlineSheetOverlay(title: String, forceShow: Bool = false) -> some View {
58+
modifier(OfflineSheetOverlayModifier(title: title, forceShow: forceShow))
5359
}
5460
}

Bitkit/Views/Wallets/Send/SendAmountView.swift

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,9 +297,20 @@ struct SendAmountView: View {
297297
guard app.selectedWalletToPayFrom == .lightning else { return }
298298
guard let bolt11 = app.scannedLightningInvoice?.bolt11 else { return }
299299

300+
let buffer: UInt64 = 2 // TODO: find out why this is needed
301+
302+
// Without usable outbound capacity (e.g. peer offline) there is nothing to estimate,
303+
// and subtracting the buffer below would underflow `UInt64`.
304+
let maxSendable = UInt64(clamping: wallet.maxSendLightningSats)
305+
guard maxSendable > buffer else {
306+
await MainActor.run {
307+
routingFee = 0
308+
}
309+
return
310+
}
311+
300312
do {
301-
let buffer: UInt64 = 2 // TODO: find out why this is needed
302-
let fee = try await wallet.estimateRoutingFees(bolt11: bolt11, amountSats: UInt64(wallet.maxSendLightningSats) - buffer)
313+
let fee = try await wallet.estimateRoutingFees(bolt11: bolt11, amountSats: maxSendable - buffer)
303314
await MainActor.run {
304315
routingFee = fee + buffer
305316
}

Bitkit/Views/Wallets/Send/SendSheet.swift

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ struct SendSheetItem: SheetItem {
4343

4444
struct SendSheet: View {
4545
@EnvironmentObject private var app: AppViewModel
46+
@EnvironmentObject private var network: NetworkMonitor
4647
@EnvironmentObject private var settings: SettingsViewModel
4748
@EnvironmentObject private var sheets: SheetViewModel
4849
@EnvironmentObject private var tagManager: TagManager
@@ -52,15 +53,17 @@ struct SendSheet: View {
5253

5354
@State private var navigationPath: [SendRoute] = []
5455
@State private var hasValidatedAfterSync = false
56+
@State private var syncTimedOut = false
5557
@State private var pinCheckContinuations: [CheckedContinuation<Bool, Never>] = []
5658

59+
/// How long the sync overlay may wait for channels to become usable before falling back
60+
private static let syncTimeoutSeconds: TimeInterval = 20
61+
5762
/// Show sync overlay when node is not ready for payments
5863
/// For lightning: need node running AND at least one usable channel (peer connected).
5964
/// If there are no channels at all, we should NOT wait behind the sync UI – that's a capacity issue, not a sync issue.
6065
/// For onchain: only need node running.
6166
private var shouldShowSyncOverlay: Bool {
62-
Logger.debug("shouldShowSyncOverlay: \(wallet.nodeLifecycleState)", context: "SendSheet")
63-
6467
// Node must be running
6568
guard wallet.nodeLifecycleState == .running else { return true }
6669

@@ -83,6 +86,22 @@ struct SendSheet: View {
8386
return false
8487
}
8588

89+
/// Identity for the sync timeout task. The timer only counts down while the overlay is visible
90+
/// and the device is online (while offline, `offlineSheetOverlay` covers the sheet and a timeout
91+
/// would act on stale state). Node readiness is part of the identity so the window restarts when
92+
/// the node reaches `.running` mid-wait and the timeout can still fall back with fresh balances.
93+
private struct SyncTimeoutPhase: Equatable {
94+
let isActive: Bool
95+
let isNodeRunning: Bool
96+
}
97+
98+
private var syncTimeoutPhase: SyncTimeoutPhase {
99+
SyncTimeoutPhase(
100+
isActive: shouldShowSyncOverlay && network.isConnected,
101+
isNodeRunning: wallet.nodeLifecycleState == .running
102+
)
103+
}
104+
86105
var body: some View {
87106
Sheet(id: .send, data: config) {
88107
if shouldShowSyncOverlay {
@@ -99,11 +118,21 @@ struct SendSheet: View {
99118
}
100119
}
101120
.animation(.easeInOut(duration: 0.3), value: shouldShowSyncOverlay)
102-
.offlineSheetOverlay(title: t("wallet__send_bitcoin"))
121+
.offlineSheetOverlay(title: t("wallet__send_bitcoin"), forceShow: syncTimedOut)
122+
.onChange(of: shouldShowSyncOverlay, initial: true) { _, isShowing in
123+
Logger.debug("shouldShowSyncOverlay: \(isShowing) (node: \(wallet.nodeLifecycleState))", context: "SendSheet")
124+
}
103125
.onAppear {
104126
tagManager.clearSelectedTags()
105127
wallet.resetSendState(speed: settings.defaultTransactionSpeed)
106128
hasValidatedAfterSync = false
129+
syncTimedOut = false
130+
131+
// A plain Send open (TabBar) must not inherit invoice state from an earlier scan,
132+
// e.g. one abandoned behind the sync overlay. Invoice-carrying opens use other routes.
133+
if config.initialRoute == .options {
134+
app.resetSendState()
135+
}
107136

108137
Task {
109138
do {
@@ -145,6 +174,38 @@ struct SendSheet: View {
145174
validatePaymentAfterSync()
146175
}
147176
}
177+
.task(id: syncTimeoutPhase) {
178+
// Bound the sync overlay wait so a peer that never connects can't leave the user
179+
// waiting indefinitely (the onChange above only fires if channels become usable).
180+
guard syncTimeoutPhase.isActive else {
181+
// Reset the connection-issues overlay only once the sync wait actually resolves;
182+
// going offline merely pauses the timer and keeps the timed-out state.
183+
if !shouldShowSyncOverlay {
184+
syncTimedOut = false
185+
}
186+
return
187+
}
188+
189+
try? await Task.sleep(for: .seconds(Self.syncTimeoutSeconds))
190+
guard !Task.isCancelled, syncTimeoutPhase.isActive, !hasValidatedAfterSync else { return }
191+
192+
handleSyncTimeout()
193+
}
194+
}
195+
196+
/// Called when the sync overlay has been visible for `syncTimeoutSeconds` without channels becoming usable.
197+
/// Unified invoices fall back to onchain; everything else shows the connection-issues screen
198+
/// (via the forced `offlineSheetOverlay`) and keeps waiting for the peer.
199+
private func handleSyncTimeout() {
200+
let canFallBackToOnchain = wallet.nodeLifecycleState == .running
201+
&& app.scannedLightningInvoice != nil
202+
&& app.scannedOnchainInvoice != nil
203+
204+
if canFallBackToOnchain {
205+
validatePaymentAfterSync(ignoreChannelWait: true)
206+
} else {
207+
syncTimedOut = true
208+
}
148209
}
149210

150211
/// Validates onchain balance and shows toast + dismisses sheet if insufficient.
@@ -198,15 +259,16 @@ struct SendSheet: View {
198259
/// Validates payment affordability after sync completes
199260
/// For lightning: falls back to onchain for unified invoices, shows error for pure lightning invoices
200261
/// For onchain: validates balance and shows error if insufficient
201-
private func validatePaymentAfterSync() {
262+
/// Pass `ignoreChannelWait: true` to validate even while channels are unusable (sync timeout).
263+
private func validatePaymentAfterSync(ignoreChannelWait: Bool = false) {
202264
// Validate lightning payment if present
203265
if let lightningInvoice = app.scannedLightningInvoice {
204266
// For lightning, if we have channels but none are usable yet, wait for them
205267
// to become usable. If there are no channels at all, or channels are already
206268
// usable, proceed with validation/fallback.
207269
// Use channelCount as fallback in case channels array is nil but count is cached
208270
let hasAnyChannels = (wallet.channels?.isEmpty == false) || wallet.channelCount > 0
209-
if hasAnyChannels, !wallet.hasUsableChannels {
271+
if hasAnyChannels, !wallet.hasUsableChannels, !ignoreChannelWait {
210272
// We have channels but none usable yet → wait
211273
return
212274
}

Bitkit/Views/Widgets/WidgetPreviewSheetView.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,15 @@ struct WidgetPreviewSheetView: View {
99
@EnvironmentObject private var app: AppViewModel
1010
@EnvironmentObject private var currency: CurrencyViewModel
1111
@EnvironmentObject private var navigation: NavigationViewModel
12+
@EnvironmentObject private var pubkyProfile: PubkyProfileManager
13+
@EnvironmentObject private var settings: SettingsViewModel
1214
@EnvironmentObject private var sheets: SheetViewModel
15+
@EnvironmentObject private var suggestionsManager: SuggestionsManager
16+
@EnvironmentObject private var wallet: WalletViewModel
1317
@EnvironmentObject private var widgets: WidgetsViewModel
1418

19+
@AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false
20+
1521
@State private var carouselPage: Int
1622
@State private var showDeleteAlert = false
1723

@@ -55,6 +61,23 @@ struct WidgetPreviewSheetView: View {
5561
carouselPage == 0 && supportsSmall ? .small : .wide
5662
}
5763

64+
private var isPaykitUIActive: Bool {
65+
PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled
66+
}
67+
68+
/// Whether the Suggestions widget would currently show no cards (mirrors what the live
69+
/// `Suggestions` view renders, including the `pubkyProfile` completion check).
70+
private var suggestionsAreEmpty: Bool {
71+
Suggestions.visibleCards(
72+
wallet: wallet,
73+
app: app,
74+
settings: settings,
75+
suggestionsManager: suggestionsManager,
76+
pubkyProfile: pubkyProfile,
77+
isPaykitUIEnabled: isPaykitUIActive
78+
).isEmpty
79+
}
80+
5881
var body: some View {
5982
VStack(alignment: .leading, spacing: 16) {
6083
SheetHeader(title: metadata.name, showBackButton: true)
@@ -261,6 +284,9 @@ struct WidgetPreviewSheetView: View {
261284
// MARK: - Actions
262285

263286
private func onSave() {
287+
if type == .suggestions, suggestionsAreEmpty {
288+
suggestionsManager.resetDismissed()
289+
}
264290
widgets.saveWidget(type, size: chosenSize)
265291
sheets.hideSheet()
266292
navigation.reset()

changelog.d/next/590.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Sending no longer waits indefinitely or crashes when Lightning channels stay unavailable: Bitkit now prefers Lightning while the peer reconnects, then falls back to an onchain payment when possible or shows the connection issues screen after a short wait.

changelog.d/next/596.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Re-adding the Suggestions widget now restores the default suggestion cards when all of them had been dismissed.

0 commit comments

Comments
 (0)