Skip to content

Commit 6299809

Browse files
authored
Merge pull request #601 from CypherPoet/fix/app-update-prompt-onboarding
fix: show app update prompt during onboarding
2 parents a296d20 + 8f8f7ec commit 6299809

7 files changed

Lines changed: 165 additions & 53 deletions

File tree

Bitkit/AppScene.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,15 @@ struct AppScene: View {
108108
) {
109109
config in ForgotPinSheet(config: config)
110110
}
111+
.sheet(
112+
item: $sheets.appUpdateSheetItem,
113+
onDismiss: {
114+
sheets.hideSheet()
115+
app.ignoreAppUpdate()
116+
}
117+
) {
118+
config in AppUpdateSheet(config: config)
119+
}
111120
.task(priority: .userInitiated, setupTask)
112121
.onChange(of: currency.hasStaleData) { _, newValue in handleCurrencyStaleData(newValue) }
113122
.onChange(of: wallet.walletExists) { _, newValue in handleWalletExistsChange(newValue) }
@@ -219,6 +228,10 @@ struct AppScene: View {
219228
.onReceive(BackupService.shared.backupFailurePublisher) { intervalMinutes in
220229
handleBackupFailure(intervalMinutes: intervalMinutes)
221230
}
231+
.onReceive(AppUpdateService.shared.$availableUpdate) { update in
232+
guard update != nil else { return }
233+
TimedSheetManager.shared.reevaluate()
234+
}
222235
}
223236

224237
private var mainContent: some View {
@@ -351,6 +364,13 @@ struct AppScene: View {
351364
// Reset these values if the wallet is wiped
352365
walletIsInitializing = nil
353366
walletInitShouldFinish = false
367+
368+
// Only the app-update sheet qualifies without a wallet, so onboarding
369+
// won't surface the other (wallet-gated) timed sheets.
370+
TimedSheetManager.shared.onPrimaryScreenEntered()
371+
}
372+
.onDisappear {
373+
TimedSheetManager.shared.onPrimaryScreenExited()
354374
}
355375
}
356376

Bitkit/MainNavView.swift

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,6 @@ struct MainNavView: View {
4848
) {
4949
config in BoostSheet(config: config)
5050
}
51-
.sheet(
52-
item: $sheets.appUpdateSheetItem,
53-
onDismiss: {
54-
sheets.hideSheet()
55-
app.ignoreAppUpdate()
56-
}
57-
) {
58-
config in AppUpdateSheet(config: config)
59-
}
6051
.sheet(
6152
item: $sheets.backupSheetItem,
6253
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 on a primary screen with no sheet already open.
116+
func reevaluate() {
117+
guard isOnPrimaryScreen, !(sheetViewModel?.isAnySheetOpen ?? false) 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)