Skip to content

Commit e3ecb17

Browse files
CypherPoetCypherPoet
authored andcommitted
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
1 parent 2c9cc65 commit e3ecb17

7 files changed

Lines changed: 171 additions & 53 deletions

File tree

Bitkit/AppScene.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,17 @@ struct AppScene: View {
100100
) {
101101
config in ForgotPinSheet(config: config)
102102
}
103+
// Presented at the root (not in MainNavView) so the non-critical update prompt
104+
// can also surface during onboarding, before a wallet exists (issue #460).
105+
.sheet(
106+
item: $sheets.appUpdateSheetItem,
107+
onDismiss: {
108+
sheets.hideSheet()
109+
app.ignoreAppUpdate()
110+
}
111+
) {
112+
config in AppUpdateSheet(config: config)
113+
}
103114
.task(priority: .userInitiated, setupTask)
104115
.onChange(of: currency.hasStaleData) { _, newValue in handleCurrencyStaleData(newValue) }
105116
.onChange(of: wallet.walletExists) { _, newValue in handleWalletExistsChange(newValue) }
@@ -202,6 +213,13 @@ struct AppScene: View {
202213
.onReceive(BackupService.shared.backupFailurePublisher) { intervalMinutes in
203214
handleBackupFailure(intervalMinutes: intervalMinutes)
204215
}
216+
.onReceive(AppUpdateService.shared.$availableUpdate) { update in
217+
// The update check finishes asynchronously. If it lands after the initial settle
218+
// check (common on a fresh first launch), re-run the evaluation so the non-critical
219+
// prompt still surfaces instead of waiting for the next primary-screen entry (issue #460).
220+
guard update != nil else { return }
221+
TimedSheetManager.shared.reevaluate()
222+
}
205223
}
206224

207225
private var mainContent: some View {
@@ -334,6 +352,14 @@ struct AppScene: View {
334352
// Reset these values if the wallet is wiped
335353
walletIsInitializing = nil
336354
walletInitShouldFinish = false
355+
356+
// Let the non-critical update prompt reach the onboarding flow too (issue #460).
357+
// Only the app-update sheet can pass shouldShow() without a wallet, so this won't
358+
// surface wallet-related timed sheets here.
359+
TimedSheetManager.shared.onPrimaryScreenEntered()
360+
}
361+
.onDisappear {
362+
TimedSheetManager.shared.onPrimaryScreenExited()
337363
}
338364
}
339365

Bitkit/MainNavView.swift

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,6 @@ struct MainNavView: View {
4646
) {
4747
config in BoostSheet(config: config)
4848
}
49-
.sheet(
50-
item: $sheets.appUpdateSheetItem,
51-
onDismiss: {
52-
sheets.hideSheet()
53-
app.ignoreAppUpdate()
54-
}
55-
) {
56-
config in AppUpdateSheet(config: config)
57-
}
5849
.sheet(
5950
item: $sheets.backupSheetItem,
6051
onDismiss: {

Bitkit/Managers/TimedSheets/AppUpdateTimedSheet.swift

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,39 +11,41 @@ struct AppUpdateTimedSheet: TimedSheetItem {
1111
private let appUpdateService = AppUpdateService.shared
1212

1313
/// App update constants
14-
private static let ASK_INTERVAL: TimeInterval = 12 * 60 * 60 // 12 hours - how long this prompt will not show after user dismisses
14+
static let ASK_INTERVAL: TimeInterval = 12 * 60 * 60 // 12 hours - how long this prompt will not show after user dismisses
1515

1616
init(appViewModel: AppViewModel) {
1717
self.appViewModel = appViewModel
1818
}
1919

2020
@MainActor
2121
func shouldShow() async -> Bool {
22+
Self.shouldShow(
23+
update: appUpdateService.availableUpdate,
24+
ignoreTimestamp: appViewModel.appUpdateIgnoreTimestamp,
25+
now: Date().timeIntervalSince1970,
26+
isE2E: Env.isE2E
27+
)
28+
}
29+
30+
/// Pure eligibility check, extracted so it can be unit-tested without an `AppViewModel` or the shared service.
31+
static func shouldShow(update: AppUpdateInfo?, ignoreTimestamp: TimeInterval, now: TimeInterval, isE2E: Bool) -> Bool {
2232
// Don't show in e2e test environment
23-
guard !Env.isE2E else {
33+
guard !isE2E else {
2434
return false
2535
}
2636

27-
// Check if enough time has passed since last ignore
28-
let currentTime = Date().timeIntervalSince1970
29-
let isTimeoutOver = currentTime - appViewModel.appUpdateIgnoreTimestamp > Self.ASK_INTERVAL
30-
31-
// Don't show if timeout hasn't passed
32-
guard isTimeoutOver else {
37+
// Don't show until enough time has passed since the user last ignored the prompt
38+
guard now - ignoreTimestamp > ASK_INTERVAL else {
3339
return false
3440
}
3541

3642
// Don't show if no update is available
37-
guard let update = appUpdateService.availableUpdate else {
38-
return false
39-
}
40-
41-
// Don't show critical updates through timed sheets (they should be handled differently)
42-
guard !update.critical else {
43+
guard let update else {
4344
return false
4445
}
4546

46-
return true
47+
// Don't show critical updates through timed sheets; they're handled at the top level in AppScene
48+
return !update.critical
4749
}
4850

4951
func onShown() {

Bitkit/Managers/TimedSheets/TimedSheetManager.swift

Lines changed: 46 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ class TimedSheetManager: ObservableObject {
3030
static let shared = TimedSheetManager()
3131

3232
private let checkDelay: TimeInterval = 2.0 // 2 seconds delay
33-
private var homeScreenTimer: Timer?
33+
private var settleTimer: Timer?
3434
private var queuedSheets: [any TimedSheetItem] = []
35-
private var isOnHomeScreen = false
35+
private var isOnPrimaryScreen = false
3636
private var currentlyShowingSheet: (any TimedSheetItem)?
3737

3838
private weak var sheetViewModel: SheetViewModel?
@@ -95,41 +95,47 @@ class TimedSheetManager: ObservableObject {
9595
Logger.debug("Removed timed sheet: \(id.rawValue)")
9696
}
9797

98-
/// Call this when user enters the home screen
99-
func onHomeScreenEntered() {
100-
guard !isOnHomeScreen else { return }
98+
/// Call this when a primary screen appears.
99+
///
100+
/// A primary screen is a top-level screen where timed sheets are allowed to surface:
101+
/// the home screen (wallet flow) or the onboarding root (no wallet yet). The app-update
102+
/// prompt is the only registered sheet whose `shouldShow()` can pass without a wallet, so
103+
/// onboarding only ever surfaces that one (see issue #460).
104+
func onPrimaryScreenEntered() {
105+
guard !isOnPrimaryScreen else { return }
101106

102-
isOnHomeScreen = true
103-
Logger.debug("User entered home screen, starting timer")
107+
isOnPrimaryScreen = true
108+
Logger.debug("Entered primary screen, starting timer")
104109

105-
// Cancel any existing timer
106-
homeScreenTimer?.invalidate()
110+
scheduleSettleCheck()
111+
}
107112

108-
// Start timer to check for sheets after delay
109-
homeScreenTimer = Timer.scheduledTimer(withTimeInterval: checkDelay, repeats: false) { [weak self] _ in
110-
Task { @MainActor in
111-
await self?.checkAndShowNextSheet()
112-
}
113-
}
113+
/// Re-check the timed-sheet queue after async state changes that may have made a sheet newly
114+
/// eligible (for example, the app-update info arriving after the initial settle check). No-ops
115+
/// unless currently on a primary screen.
116+
func reevaluate() {
117+
guard isOnPrimaryScreen else { return }
118+
Logger.debug("Re-evaluating timed sheets after external state change")
119+
scheduleSettleCheck()
114120
}
115121

116-
/// Call this when user leaves the home screen
117-
func onHomeScreenExited() {
118-
guard isOnHomeScreen else { return }
122+
/// Call this when the primary screen disappears.
123+
func onPrimaryScreenExited() {
124+
guard isOnPrimaryScreen else { return }
119125

120-
isOnHomeScreen = false
121-
Logger.debug("User exited home screen, cancelling timer")
126+
isOnPrimaryScreen = false
127+
Logger.debug("Exited primary screen, cancelling timer")
122128

123129
// Cancel timer
124-
homeScreenTimer?.invalidate()
125-
homeScreenTimer = nil
130+
settleTimer?.invalidate()
131+
settleTimer = nil
126132
}
127133

128134
/// Call this when any sheet is shown (to prevent showing timed sheets)
129135
func onSheetShown() {
130136
// If a sheet is shown, cancel any pending timed sheet checks
131-
homeScreenTimer?.invalidate()
132-
homeScreenTimer = nil
137+
settleTimer?.invalidate()
138+
settleTimer = nil
133139
}
134140

135141
/// Call this when a sheet is dismissed
@@ -141,6 +147,19 @@ class TimedSheetManager: ObservableObject {
141147
}
142148
}
143149

150+
/// (Re)start the settle timer that runs the queue check after a short delay.
151+
private func scheduleSettleCheck() {
152+
// Cancel any existing timer
153+
settleTimer?.invalidate()
154+
155+
// Start timer to check for sheets after delay
156+
settleTimer = Timer.scheduledTimer(withTimeInterval: checkDelay, repeats: false) { [weak self] _ in
157+
Task { @MainActor in
158+
await self?.checkAndShowNextSheet()
159+
}
160+
}
161+
}
162+
144163
/// Check the queue and show the highest priority sheet that should be shown
145164
private func checkAndShowNextSheet() async {
146165
guard let sheetViewModel else {
@@ -154,9 +173,9 @@ class TimedSheetManager: ObservableObject {
154173
return
155174
}
156175

157-
// Don't show if not on home screen anymore
158-
guard isOnHomeScreen else {
159-
Logger.debug("No longer on home screen, skipping timed sheet check")
176+
// Don't show if no longer on a primary screen
177+
guard isOnPrimaryScreen else {
178+
Logger.debug("No longer on a primary screen, skipping timed sheet check")
160179
return
161180
}
162181

Bitkit/Views/HomeScreen.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,11 @@ struct HomeScreen: View {
8787
}
8888
.navigationBarHidden(true)
8989
.onAppear {
90-
TimedSheetManager.shared.onHomeScreenEntered()
90+
TimedSheetManager.shared.onPrimaryScreenEntered()
9191
consumeRequestedHomePage()
9292
}
9393
.onDisappear {
94-
TimedSheetManager.shared.onHomeScreenExited()
94+
TimedSheetManager.shared.onPrimaryScreenExited()
9595
}
9696
.onChange(of: app.requestedHomePage) { _, _ in
9797
consumeRequestedHomePage()
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
@testable import Bitkit
2+
import XCTest
3+
4+
final class AppUpdateTimedSheetTests: XCTestCase {
5+
private let askInterval = AppUpdateTimedSheet.ASK_INTERVAL
6+
7+
private func makeUpdate(critical: Bool) -> AppUpdateInfo {
8+
AppUpdateInfo(buildNumber: 200, version: "1.2.3", url: "https://example.com/app", notes: nil, critical: critical)
9+
}
10+
11+
func testShownWhenNonCriticalUpdateAndIntervalElapsed() {
12+
XCTAssertTrue(
13+
AppUpdateTimedSheet.shouldShow(
14+
update: makeUpdate(critical: false),
15+
ignoreTimestamp: 0,
16+
now: askInterval + 1,
17+
isE2E: false
18+
)
19+
)
20+
}
21+
22+
func testHiddenWhenNoUpdateAvailable() {
23+
XCTAssertFalse(
24+
AppUpdateTimedSheet.shouldShow(
25+
update: nil,
26+
ignoreTimestamp: 0,
27+
now: askInterval + 1,
28+
isE2E: false
29+
)
30+
)
31+
}
32+
33+
func testHiddenForCriticalUpdate() {
34+
// Critical updates are handled by the full-screen takeover in AppScene, not this sheet.
35+
XCTAssertFalse(
36+
AppUpdateTimedSheet.shouldShow(
37+
update: makeUpdate(critical: true),
38+
ignoreTimestamp: 0,
39+
now: askInterval + 1,
40+
isE2E: false
41+
)
42+
)
43+
}
44+
45+
func testHiddenWithinAskInterval() {
46+
// Ignored one hour ago, still inside the 12h quiet window.
47+
XCTAssertFalse(
48+
AppUpdateTimedSheet.shouldShow(
49+
update: makeUpdate(critical: false),
50+
ignoreTimestamp: 0,
51+
now: 60 * 60,
52+
isE2E: false
53+
)
54+
)
55+
}
56+
57+
func testIntervalBoundaryIsExclusive() {
58+
// Exactly 12h elapsed is not strictly greater than the interval, so still hidden.
59+
XCTAssertFalse(
60+
AppUpdateTimedSheet.shouldShow(
61+
update: makeUpdate(critical: false),
62+
ignoreTimestamp: 0,
63+
now: askInterval,
64+
isE2E: false
65+
)
66+
)
67+
}
68+
69+
func testHiddenInE2EEnvironment() {
70+
XCTAssertFalse(
71+
AppUpdateTimedSheet.shouldShow(
72+
update: makeUpdate(critical: false),
73+
ignoreTimestamp: 0,
74+
now: askInterval + 1,
75+
isE2E: true
76+
)
77+
)
78+
}
79+
}

changelog.d/next/601.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Bitkit now shows the optional update prompt during onboarding too, so it is no longer missed on a fresh first launch.

0 commit comments

Comments
 (0)