commit 9567ddc99a985a8ece041c3fdd4066ded1a0f77d
parent 62b669051b89c5197485697d3bf8c8523e7fbd22
Author: Michael Camilleri <[email protected]>
Date: Sun, 5 Jul 2026 23:26:02 +0900
Show tips from third cold launch
Diffstat:
3 files changed, 74 insertions(+), 4 deletions(-)
diff --git a/Crossmate/Models/TipStore.swift b/Crossmate/Models/TipStore.swift
@@ -112,6 +112,13 @@ enum TipCatalog {
@MainActor
@Observable
final class TipStore {
+ /// Cold launches that pass tip-free before the banner ever appears. A brand
+ /// new user's first couple of visits to the Game List should be about the
+ /// library, not onboarding chrome, so tips hold off until the launch after
+ /// this many. Counting is cold-launch based (see `noteColdLaunch`), matching
+ /// how tips are surfaced one-per-launch.
+ static let launchesBeforeTips = 2
+
/// When true the user chose "Never show me tips"; no tip is surfaced until
/// they re-enable from Settings.
var isDisabled: Bool {
@@ -124,11 +131,19 @@ final class TipStore {
didSet { defaults.set(Array(dismissedIDs), forKey: dismissedKey) }
}
+ /// Cold launches noted so far, persisted so the warm-up survives restarts.
+ /// Capped just past `launchesBeforeTips` — once tips are due there's no
+ /// reason to keep counting, so the stored value can't grow without bound.
+ private var coldLaunchCount: Int {
+ didSet { defaults.set(coldLaunchCount, forKey: coldLaunchCountKey) }
+ }
+
@ObservationIgnored private let defaults: UserDefaults
@ObservationIgnored private let catalog: [Tip]
@ObservationIgnored private let platform: TipPlatform
@ObservationIgnored private let disabledKey = "tipsDisabled"
@ObservationIgnored private let dismissedKey = "dismissedTipIDs"
+ @ObservationIgnored private let coldLaunchCountKey = "tipColdLaunchCount"
init(
defaults: UserDefaults = .standard,
@@ -140,6 +155,15 @@ final class TipStore {
self.platform = platform
self.isDisabled = defaults.bool(forKey: disabledKey)
self.dismissedIDs = Set(defaults.stringArray(forKey: dismissedKey) ?? [])
+ self.coldLaunchCount = defaults.integer(forKey: coldLaunchCountKey)
+ }
+
+ /// Records that a fresh process has reached the Game List. Call once per
+ /// cold launch, ahead of `currentTip()`. Stops incrementing once the warm-up
+ /// is satisfied so the persisted count stays bounded.
+ func noteColdLaunch() {
+ guard coldLaunchCount <= Self.launchesBeforeTips else { return }
+ coldLaunchCount += 1
}
/// Catalog tips that apply to this device, in order. Tips scoped to other
@@ -150,9 +174,11 @@ final class TipStore {
}
/// The tip to surface now: the first applicable tip the user hasn't
- /// dismissed, or `nil` when tips are disabled or every one has been seen.
+ /// dismissed, or `nil` when tips are disabled, still within the launch
+ /// warm-up (`launchesBeforeTips`), or every one has been seen.
func currentTip() -> Tip? {
guard !isDisabled else { return nil }
+ guard coldLaunchCount > Self.launchesBeforeTips else { return nil }
return firstUndismissedTip()
}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -724,7 +724,11 @@ final class AppServices {
// AnnouncementCenter is empty on a fresh process, so this re-posts the
// next undismissed tip on each cold start; a warm resume doesn't re-run
// start(), so no tip reappears mid-session. Independent of iCloud sync,
- // so it runs ahead of the sync-enablement guard below.
+ // so it runs ahead of the sync-enablement guard below. The launch note
+ // holds tips off for a new user's first couple of visits to the Game
+ // List (see TipStore.launchesBeforeTips), so it must run before the tip
+ // is read.
+ tips.noteColdLaunch()
if let tip = tips.currentTip() {
announcements.post(tip.liveAnnouncement())
}
diff --git a/Tests/Unit/TipStoreTests.swift b/Tests/Unit/TipStoreTests.swift
@@ -18,7 +18,16 @@ struct TipStoreTests {
) -> (store: TipStore, defaults: UserDefaults) {
// Fresh UserDefaults suite per test to avoid cross-test pollution.
let defaults = defaults ?? UserDefaults(suiteName: "test-\(UUID().uuidString)")!
- return (TipStore(defaults: defaults, catalog: catalog), defaults)
+ // Past the launch warm-up by default so tests can focus on dismissal,
+ // disable, and platform logic. The warm-up itself is covered separately.
+ return (warmedUp(TipStore(defaults: defaults, catalog: catalog)), defaults)
+ }
+
+ /// Notes enough cold launches to move `store` past the tip warm-up.
+ @discardableResult
+ private func warmedUp(_ store: TipStore) -> TipStore {
+ for _ in 0...TipStore.launchesBeforeTips { store.noteColdLaunch() }
+ return store
}
@Test("currentTip returns the first catalog tip by default")
@@ -67,6 +76,37 @@ struct TipStoreTests {
#expect(reloaded.currentTip()?.id == "two")
}
+ @Test("No tip surfaces during the launch warm-up")
+ func warmUpSuppressesTips() {
+ let defaults = UserDefaults(suiteName: "test-\(UUID().uuidString)")!
+ let store = TipStore(defaults: defaults, catalog: catalog)
+ // A brand new store surfaces nothing, and each warm-up launch stays
+ // tip-free.
+ #expect(store.currentTip() == nil)
+ for _ in 0..<TipStore.launchesBeforeTips {
+ store.noteColdLaunch()
+ #expect(store.currentTip() == nil)
+ }
+ // The launch after the warm-up surfaces the first tip.
+ store.noteColdLaunch()
+ #expect(store.currentTip()?.id == "one")
+ }
+
+ @Test("The launch warm-up persists across instances")
+ func warmUpPersists() {
+ let defaults = UserDefaults(suiteName: "test-\(UUID().uuidString)")!
+ // Note every warm-up launch but one on a first instance…
+ let first = TipStore(defaults: defaults, catalog: catalog)
+ for _ in 0..<TipStore.launchesBeforeTips { first.noteColdLaunch() }
+
+ // …a reload picks up the persisted count: still within the warm-up, so
+ // the final launch is what tips over into surfacing a tip.
+ let reloaded = TipStore(defaults: defaults, catalog: catalog)
+ #expect(reloaded.currentTip() == nil)
+ reloaded.noteColdLaunch()
+ #expect(reloaded.currentTip()?.id == "one")
+ }
+
@Test("Tips scoped to other platforms are filtered out")
func platformScoping() {
let catalog = [
@@ -75,7 +115,7 @@ struct TipStoreTests {
Tip(id: "iphone", title: "iPhone", body: "iPhone only", only: [.iPhone]),
]
let defaults = UserDefaults(suiteName: "test-\(UUID().uuidString)")!
- let onPhone = TipStore(defaults: defaults, catalog: catalog, platform: .iPhone)
+ let onPhone = warmedUp(TipStore(defaults: defaults, catalog: catalog, platform: .iPhone))
#expect(onPhone.visibleTips.map(\.id) == ["both", "iphone"])
// The first applicable tip skips the iPad-only one.
#expect(onPhone.currentTip()?.id == "both")