Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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()
}
Comment thread
CypherPoet marked this conversation as resolved.
}

private var mainContent: some View {
Expand Down Expand Up @@ -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()
}
}

Expand Down
9 changes: 0 additions & 9 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
32 changes: 17 additions & 15 deletions Bitkit/Managers/TimedSheets/AppUpdateTimedSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,39 +11,41 @@ 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
}

@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() {
Expand Down
73 changes: 46 additions & 27 deletions Bitkit/Managers/TimedSheets/TimedSheetManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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 on a primary screen with no sheet already open.
func reevaluate() {
guard isOnPrimaryScreen, !(sheetViewModel?.isAnySheetOpen ?? false) else { return }
Logger.debug("Re-evaluating timed sheets after external state change")
scheduleSettleCheck()
}
Comment thread
CypherPoet marked this conversation as resolved.

/// 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
Expand All @@ -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 {
Expand All @@ -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
}

Expand Down
4 changes: 2 additions & 2 deletions Bitkit/Views/HomeScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
79 changes: 79 additions & 0 deletions BitkitTests/AppUpdateTimedSheetTests.swift
Original file line number Diff line number Diff line change
@@ -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
)
)
}
}
1 change: 1 addition & 0 deletions changelog.d/next/601.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Bitkit now shows the optional update prompt during onboarding too, so it is no longer missed on a fresh first launch.
Loading