From e3ecb1791e178f80bb4dcd3a602c77799b0ac9ce Mon Sep 17 00:00:00 2001 From: CypherPoet Date: Wed, 17 Jun 2026 15:19:03 -0500 Subject: [PATCH 1/3] fix: show app update prompt during onboarding The non-critical update prompt was only evaluated and presented on the home screen, which exists only after a wallet is loaded, so onboarding and a fresh first launch could miss it. Present it at the app root, evaluate the timed-sheet queue from the onboarding screen too, and re-check when the update result arrives. Fixes #460 --- Bitkit/AppScene.swift | 26 ++++++ Bitkit/MainNavView.swift | 9 --- .../TimedSheets/AppUpdateTimedSheet.swift | 32 ++++---- .../TimedSheets/TimedSheetManager.swift | 73 ++++++++++------- Bitkit/Views/HomeScreen.swift | 4 +- BitkitTests/AppUpdateTimedSheetTests.swift | 79 +++++++++++++++++++ changelog.d/next/601.fixed.md | 1 + 7 files changed, 171 insertions(+), 53 deletions(-) create mode 100644 BitkitTests/AppUpdateTimedSheetTests.swift create mode 100644 changelog.d/next/601.fixed.md diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 1507d7d06..038fa5345 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -100,6 +100,17 @@ struct AppScene: View { ) { config in ForgotPinSheet(config: config) } + // Presented at the root (not in MainNavView) so the non-critical update prompt + // can also surface during onboarding, before a wallet exists (issue #460). + .sheet( + item: $sheets.appUpdateSheetItem, + onDismiss: { + sheets.hideSheet() + app.ignoreAppUpdate() + } + ) { + config in AppUpdateSheet(config: config) + } .task(priority: .userInitiated, setupTask) .onChange(of: currency.hasStaleData) { _, newValue in handleCurrencyStaleData(newValue) } .onChange(of: wallet.walletExists) { _, newValue in handleWalletExistsChange(newValue) } @@ -202,6 +213,13 @@ struct AppScene: View { .onReceive(BackupService.shared.backupFailurePublisher) { intervalMinutes in handleBackupFailure(intervalMinutes: intervalMinutes) } + .onReceive(AppUpdateService.shared.$availableUpdate) { update in + // The update check finishes asynchronously. If it lands after the initial settle + // check (common on a fresh first launch), re-run the evaluation so the non-critical + // prompt still surfaces instead of waiting for the next primary-screen entry (issue #460). + guard update != nil else { return } + TimedSheetManager.shared.reevaluate() + } } private var mainContent: some View { @@ -334,6 +352,14 @@ struct AppScene: View { // Reset these values if the wallet is wiped walletIsInitializing = nil walletInitShouldFinish = false + + // Let the non-critical update prompt reach the onboarding flow too (issue #460). + // Only the app-update sheet can pass shouldShow() without a wallet, so this won't + // surface wallet-related timed sheets here. + TimedSheetManager.shared.onPrimaryScreenEntered() + } + .onDisappear { + TimedSheetManager.shared.onPrimaryScreenExited() } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 90a46fa2b..c85c8172c 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -46,15 +46,6 @@ struct MainNavView: View { ) { config in BoostSheet(config: config) } - .sheet( - item: $sheets.appUpdateSheetItem, - onDismiss: { - sheets.hideSheet() - app.ignoreAppUpdate() - } - ) { - config in AppUpdateSheet(config: config) - } .sheet( item: $sheets.backupSheetItem, onDismiss: { diff --git a/Bitkit/Managers/TimedSheets/AppUpdateTimedSheet.swift b/Bitkit/Managers/TimedSheets/AppUpdateTimedSheet.swift index 581e8c79d..818a47c38 100644 --- a/Bitkit/Managers/TimedSheets/AppUpdateTimedSheet.swift +++ b/Bitkit/Managers/TimedSheets/AppUpdateTimedSheet.swift @@ -11,7 +11,7 @@ struct AppUpdateTimedSheet: TimedSheetItem { private let appUpdateService = AppUpdateService.shared /// App update constants - private static let ASK_INTERVAL: TimeInterval = 12 * 60 * 60 // 12 hours - how long this prompt will not show after user dismisses + static let ASK_INTERVAL: TimeInterval = 12 * 60 * 60 // 12 hours - how long this prompt will not show after user dismisses init(appViewModel: AppViewModel) { self.appViewModel = appViewModel @@ -19,31 +19,33 @@ struct AppUpdateTimedSheet: TimedSheetItem { @MainActor func shouldShow() async -> Bool { + Self.shouldShow( + update: appUpdateService.availableUpdate, + ignoreTimestamp: appViewModel.appUpdateIgnoreTimestamp, + now: Date().timeIntervalSince1970, + isE2E: Env.isE2E + ) + } + + /// Pure eligibility check, extracted so it can be unit-tested without an `AppViewModel` or the shared service. + static func shouldShow(update: AppUpdateInfo?, ignoreTimestamp: TimeInterval, now: TimeInterval, isE2E: Bool) -> Bool { // Don't show in e2e test environment - guard !Env.isE2E else { + guard !isE2E else { return false } - // Check if enough time has passed since last ignore - let currentTime = Date().timeIntervalSince1970 - let isTimeoutOver = currentTime - appViewModel.appUpdateIgnoreTimestamp > Self.ASK_INTERVAL - - // Don't show if timeout hasn't passed - guard isTimeoutOver else { + // Don't show until enough time has passed since the user last ignored the prompt + guard now - ignoreTimestamp > ASK_INTERVAL else { return false } // Don't show if no update is available - guard let update = appUpdateService.availableUpdate else { - return false - } - - // Don't show critical updates through timed sheets (they should be handled differently) - guard !update.critical else { + guard let update else { return false } - return true + // Don't show critical updates through timed sheets; they're handled at the top level in AppScene + return !update.critical } func onShown() { diff --git a/Bitkit/Managers/TimedSheets/TimedSheetManager.swift b/Bitkit/Managers/TimedSheets/TimedSheetManager.swift index 72dff42e8..a5d9240c8 100644 --- a/Bitkit/Managers/TimedSheets/TimedSheetManager.swift +++ b/Bitkit/Managers/TimedSheets/TimedSheetManager.swift @@ -30,9 +30,9 @@ class TimedSheetManager: ObservableObject { static let shared = TimedSheetManager() private let checkDelay: TimeInterval = 2.0 // 2 seconds delay - private var homeScreenTimer: Timer? + private var settleTimer: Timer? private var queuedSheets: [any TimedSheetItem] = [] - private var isOnHomeScreen = false + private var isOnPrimaryScreen = false private var currentlyShowingSheet: (any TimedSheetItem)? private weak var sheetViewModel: SheetViewModel? @@ -95,41 +95,47 @@ class TimedSheetManager: ObservableObject { Logger.debug("Removed timed sheet: \(id.rawValue)") } - /// Call this when user enters the home screen - func onHomeScreenEntered() { - guard !isOnHomeScreen else { return } + /// Call this when a primary screen appears. + /// + /// A primary screen is a top-level screen where timed sheets are allowed to surface: + /// the home screen (wallet flow) or the onboarding root (no wallet yet). The app-update + /// prompt is the only registered sheet whose `shouldShow()` can pass without a wallet, so + /// onboarding only ever surfaces that one (see issue #460). + func onPrimaryScreenEntered() { + guard !isOnPrimaryScreen else { return } - isOnHomeScreen = true - Logger.debug("User entered home screen, starting timer") + isOnPrimaryScreen = true + Logger.debug("Entered primary screen, starting timer") - // Cancel any existing timer - homeScreenTimer?.invalidate() + scheduleSettleCheck() + } - // Start timer to check for sheets after delay - homeScreenTimer = Timer.scheduledTimer(withTimeInterval: checkDelay, repeats: false) { [weak self] _ in - Task { @MainActor in - await self?.checkAndShowNextSheet() - } - } + /// Re-check the timed-sheet queue after async state changes that may have made a sheet newly + /// eligible (for example, the app-update info arriving after the initial settle check). No-ops + /// unless currently on a primary screen. + func reevaluate() { + guard isOnPrimaryScreen else { return } + Logger.debug("Re-evaluating timed sheets after external state change") + scheduleSettleCheck() } - /// Call this when user leaves the home screen - func onHomeScreenExited() { - guard isOnHomeScreen else { return } + /// Call this when the primary screen disappears. + func onPrimaryScreenExited() { + guard isOnPrimaryScreen else { return } - isOnHomeScreen = false - Logger.debug("User exited home screen, cancelling timer") + isOnPrimaryScreen = false + Logger.debug("Exited primary screen, cancelling timer") // Cancel timer - homeScreenTimer?.invalidate() - homeScreenTimer = nil + settleTimer?.invalidate() + settleTimer = nil } /// Call this when any sheet is shown (to prevent showing timed sheets) func onSheetShown() { // If a sheet is shown, cancel any pending timed sheet checks - homeScreenTimer?.invalidate() - homeScreenTimer = nil + settleTimer?.invalidate() + settleTimer = nil } /// Call this when a sheet is dismissed @@ -141,6 +147,19 @@ class TimedSheetManager: ObservableObject { } } + /// (Re)start the settle timer that runs the queue check after a short delay. + private func scheduleSettleCheck() { + // Cancel any existing timer + settleTimer?.invalidate() + + // Start timer to check for sheets after delay + settleTimer = Timer.scheduledTimer(withTimeInterval: checkDelay, repeats: false) { [weak self] _ in + Task { @MainActor in + await self?.checkAndShowNextSheet() + } + } + } + /// Check the queue and show the highest priority sheet that should be shown private func checkAndShowNextSheet() async { guard let sheetViewModel else { @@ -154,9 +173,9 @@ class TimedSheetManager: ObservableObject { return } - // Don't show if not on home screen anymore - guard isOnHomeScreen else { - Logger.debug("No longer on home screen, skipping timed sheet check") + // Don't show if no longer on a primary screen + guard isOnPrimaryScreen else { + Logger.debug("No longer on a primary screen, skipping timed sheet check") return } diff --git a/Bitkit/Views/HomeScreen.swift b/Bitkit/Views/HomeScreen.swift index bc8f072a7..71e05f010 100644 --- a/Bitkit/Views/HomeScreen.swift +++ b/Bitkit/Views/HomeScreen.swift @@ -87,11 +87,11 @@ struct HomeScreen: View { } .navigationBarHidden(true) .onAppear { - TimedSheetManager.shared.onHomeScreenEntered() + TimedSheetManager.shared.onPrimaryScreenEntered() consumeRequestedHomePage() } .onDisappear { - TimedSheetManager.shared.onHomeScreenExited() + TimedSheetManager.shared.onPrimaryScreenExited() } .onChange(of: app.requestedHomePage) { _, _ in consumeRequestedHomePage() diff --git a/BitkitTests/AppUpdateTimedSheetTests.swift b/BitkitTests/AppUpdateTimedSheetTests.swift new file mode 100644 index 000000000..7acd8ebe2 --- /dev/null +++ b/BitkitTests/AppUpdateTimedSheetTests.swift @@ -0,0 +1,79 @@ +@testable import Bitkit +import XCTest + +final class AppUpdateTimedSheetTests: XCTestCase { + private let askInterval = AppUpdateTimedSheet.ASK_INTERVAL + + private func makeUpdate(critical: Bool) -> AppUpdateInfo { + AppUpdateInfo(buildNumber: 200, version: "1.2.3", url: "https://example.com/app", notes: nil, critical: critical) + } + + func testShownWhenNonCriticalUpdateAndIntervalElapsed() { + XCTAssertTrue( + AppUpdateTimedSheet.shouldShow( + update: makeUpdate(critical: false), + ignoreTimestamp: 0, + now: askInterval + 1, + isE2E: false + ) + ) + } + + func testHiddenWhenNoUpdateAvailable() { + XCTAssertFalse( + AppUpdateTimedSheet.shouldShow( + update: nil, + ignoreTimestamp: 0, + now: askInterval + 1, + isE2E: false + ) + ) + } + + func testHiddenForCriticalUpdate() { + // Critical updates are handled by the full-screen takeover in AppScene, not this sheet. + XCTAssertFalse( + AppUpdateTimedSheet.shouldShow( + update: makeUpdate(critical: true), + ignoreTimestamp: 0, + now: askInterval + 1, + isE2E: false + ) + ) + } + + func testHiddenWithinAskInterval() { + // Ignored one hour ago, still inside the 12h quiet window. + XCTAssertFalse( + AppUpdateTimedSheet.shouldShow( + update: makeUpdate(critical: false), + ignoreTimestamp: 0, + now: 60 * 60, + isE2E: false + ) + ) + } + + func testIntervalBoundaryIsExclusive() { + // Exactly 12h elapsed is not strictly greater than the interval, so still hidden. + XCTAssertFalse( + AppUpdateTimedSheet.shouldShow( + update: makeUpdate(critical: false), + ignoreTimestamp: 0, + now: askInterval, + isE2E: false + ) + ) + } + + func testHiddenInE2EEnvironment() { + XCTAssertFalse( + AppUpdateTimedSheet.shouldShow( + update: makeUpdate(critical: false), + ignoreTimestamp: 0, + now: askInterval + 1, + isE2E: true + ) + ) + } +} diff --git a/changelog.d/next/601.fixed.md b/changelog.d/next/601.fixed.md new file mode 100644 index 000000000..718e59cec --- /dev/null +++ b/changelog.d/next/601.fixed.md @@ -0,0 +1 @@ +Bitkit now shows the optional update prompt during onboarding too, so it is no longer missed on a fresh first launch. From 2d5a2ada8fcbf318a80322ded3389a8fda535caa Mon Sep 17 00:00:00 2001 From: CypherPoet Date: Wed, 17 Jun 2026 15:49:08 -0500 Subject: [PATCH 2/3] refactor: skip reevaluate() timer when a sheet is open reevaluate() now bails when a sheet is already showing, mirroring the guard in checkAndShowNextSheet(), so a late update publish no longer arms a settle timer that would only no-op. Addresses review on #601. --- Bitkit/Managers/TimedSheets/TimedSheetManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/TimedSheets/TimedSheetManager.swift b/Bitkit/Managers/TimedSheets/TimedSheetManager.swift index a5d9240c8..2d5a771e8 100644 --- a/Bitkit/Managers/TimedSheets/TimedSheetManager.swift +++ b/Bitkit/Managers/TimedSheets/TimedSheetManager.swift @@ -112,9 +112,9 @@ class TimedSheetManager: ObservableObject { /// Re-check the timed-sheet queue after async state changes that may have made a sheet newly /// eligible (for example, the app-update info arriving after the initial settle check). No-ops - /// unless currently on a primary screen. + /// unless on a primary screen with no sheet already open. func reevaluate() { - guard isOnPrimaryScreen else { return } + guard isOnPrimaryScreen, !(sheetViewModel?.isAnySheetOpen ?? false) else { return } Logger.debug("Re-evaluating timed sheets after external state change") scheduleSettleCheck() } From 8f8f7ec52a64bb6205408c9abdf37d9da8bc3571 Mon Sep 17 00:00:00 2001 From: CypherPoet <46851636+CypherPoet@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:00:38 -0500 Subject: [PATCH 3/3] refactor: trim inline comments in AppScene per review Remove the self-explanatory comment on the root app-update sheet and the onReceive note (reevaluate()'s doc already covers the late-arrival why). Keep a short note only for the non-obvious "app-update is the sole wallet-independent timed sheet" invariant at the onboarding trigger. --- Bitkit/AppScene.swift | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 038fa5345..19fe66bc7 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -100,8 +100,6 @@ struct AppScene: View { ) { config in ForgotPinSheet(config: config) } - // Presented at the root (not in MainNavView) so the non-critical update prompt - // can also surface during onboarding, before a wallet exists (issue #460). .sheet( item: $sheets.appUpdateSheetItem, onDismiss: { @@ -214,9 +212,6 @@ struct AppScene: View { handleBackupFailure(intervalMinutes: intervalMinutes) } .onReceive(AppUpdateService.shared.$availableUpdate) { update in - // The update check finishes asynchronously. If it lands after the initial settle - // check (common on a fresh first launch), re-run the evaluation so the non-critical - // prompt still surfaces instead of waiting for the next primary-screen entry (issue #460). guard update != nil else { return } TimedSheetManager.shared.reevaluate() } @@ -353,9 +348,8 @@ struct AppScene: View { walletIsInitializing = nil walletInitShouldFinish = false - // Let the non-critical update prompt reach the onboarding flow too (issue #460). - // Only the app-update sheet can pass shouldShow() without a wallet, so this won't - // surface wallet-related timed sheets here. + // Only the app-update sheet qualifies without a wallet, so onboarding + // won't surface the other (wallet-gated) timed sheets. TimedSheetManager.shared.onPrimaryScreenEntered() } .onDisappear {