crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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:
MCrossmate/Models/TipStore.swift | 28+++++++++++++++++++++++++++-
MCrossmate/Services/AppServices.swift | 6+++++-
MTests/Unit/TipStoreTests.swift | 44++++++++++++++++++++++++++++++++++++++++++--
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")