TipStore.swift (7688B)
1 import Foundation 2 import Observation 3 import UIKit 4 5 /// A device family a tip can be scoped to. The app runs on iPhone and iPad; a 6 /// tip lists the platforms it should appear on (its `only` set), defaulting to 7 /// all of them. 8 enum TipPlatform: Hashable, CaseIterable { 9 case iPhone 10 case iPad 11 12 /// Every platform — the default `only` value, i.e. "show everywhere". 13 static let all: Set<TipPlatform> = Set(allCases) 14 15 /// The platform of the device the app is running on. 16 @MainActor static var current: TipPlatform { 17 UIDevice.current.userInterfaceIdiom == .pad ? .iPad : .iPhone 18 } 19 } 20 21 /// One onboarding tip shown in the Game List announcement banner and listed in 22 /// `Settings → Tips`. The catalog is ordered; `TipStore` walks it in order, 23 /// surfacing the first tip the user has not yet dismissed. 24 struct Tip: Identifiable, Equatable { 25 let id: String 26 let title: String 27 let body: String 28 /// Platforms this tip appears on. Defaults to all of them; e.g. `[.iPad]` 29 /// makes it iPad-only. 30 var only: Set<TipPlatform> = TipPlatform.all 31 } 32 33 enum TipCatalog { 34 /// Ordered tips. One surfaces per cold launch until each has been dismissed. 35 /// Body wraps to at most three lines in the banner (`AnnouncementBanner` 36 /// caps the body at `lineLimit(3)`), title to one. Scope a tip to a device 37 /// family with `only:` (e.g. the iPad-only hardware-keyboard tip). 38 static let all: [Tip] = [ 39 Tip( 40 id: "solve-together", 41 title: "Try a Multiplayer Crossword", 42 body: "Start a puzzle and choose Share Puzzle from the Players menu." 43 ), 44 Tip( 45 id: "connect-providers", 46 title: "Get More Puzzles", 47 body: "Add an external provider in Settings. Connected providers appear on the new puzzle screen." 48 ), 49 Tip( 50 id: "pick-your-colour", 51 title: "Pick Your Colour", 52 body: "Choose your colour. Crossmate selects a different colour each time for friends." 53 ), 54 Tip( 55 id: "get-attention", 56 title: "Announce Your Availability", 57 body: "Nudge your friends from the Players menu in a shared puzzle." 58 ), 59 Tip( 60 id: "be-notified", 61 title: "Control Your Notifications", 62 body: "Adjust the notifications you want to see in Settings." 63 ), 64 Tip( 65 id: "take-a-hint", 66 title: "Get Unstuck in Difficult Puzzles", 67 body: "Use the Hints menu in a puzzle to check your letters or get an answer." 68 ), 69 Tip( 70 id: "import-puzzles", 71 title: "Import Your Own Puzzles", 72 body: "Download Across Lite or XD files in Safari or save to the Crossmate folder in iCloud Drive." 73 ), 74 Tip( 75 id: "undo-redo", 76 title: "Undo Mistakes Easily", 77 body: "Press the overflow button on the onscreen keyboard to access undo and redo." 78 ), 79 Tip( 80 id: "hardware-keyboard", 81 title: "Use a Hardware Keyboard", 82 body: "Connect a keyboard to solve more swiftly.", 83 only: [.iPad] 84 ), 85 ] 86 } 87 88 /// Device-local record of which tips the user has dismissed, plus a global 89 /// opt-out flag, persisted in `UserDefaults`. Modelled on `GameViewedStore`: 90 /// never synced, purely local presentation state. `@Observable` so the 91 /// `Settings → Tips` re-enable control reacts to the opt-out flag flipping. 92 @MainActor 93 @Observable 94 final class TipStore { 95 /// When true the user chose "Never show me tips"; no tip is surfaced until 96 /// they re-enable from Settings. 97 var isDisabled: Bool { 98 didSet { defaults.set(isDisabled, forKey: disabledKey) } 99 } 100 101 /// Catalog ids the user has dismissed from the Game List banner. Once 102 /// dismissed, a tip never returns there (but stays in the Settings archive). 103 private var dismissedIDs: Set<String> { 104 didSet { defaults.set(Array(dismissedIDs), forKey: dismissedKey) } 105 } 106 107 @ObservationIgnored private let defaults: UserDefaults 108 @ObservationIgnored private let catalog: [Tip] 109 @ObservationIgnored private let platform: TipPlatform 110 @ObservationIgnored private let disabledKey = "tipsDisabled" 111 @ObservationIgnored private let dismissedKey = "dismissedTipIDs" 112 113 init( 114 defaults: UserDefaults = .standard, 115 catalog: [Tip] = TipCatalog.all, 116 platform: TipPlatform = .current 117 ) { 118 self.defaults = defaults 119 self.catalog = catalog 120 self.platform = platform 121 self.isDisabled = defaults.bool(forKey: disabledKey) 122 self.dismissedIDs = Set(defaults.stringArray(forKey: dismissedKey) ?? []) 123 } 124 125 /// Catalog tips that apply to this device, in order. Tips scoped to other 126 /// platforms via `only` are filtered out, so neither the Game List banner 127 /// nor the Settings archive surfaces them here. 128 var visibleTips: [Tip] { 129 catalog.filter { $0.only.contains(platform) } 130 } 131 132 /// The tip to surface now: the first applicable tip the user hasn't 133 /// dismissed, or `nil` when tips are disabled or every one has been seen. 134 func currentTip() -> Tip? { 135 guard !isDisabled else { return nil } 136 return firstUndismissedTip() 137 } 138 139 /// The first applicable tip the user hasn't dismissed, regardless of the 140 /// disabled flag. This is the tip currently posted to the banner (a cold 141 /// launch posts it before any toggle could disable tips), so turning tips 142 /// off needs it to know which announcement to clear. 143 func firstUndismissedTip() -> Tip? { 144 visibleTips.first { !dismissedIDs.contains($0.id) } 145 } 146 147 /// Records that `id` was dismissed from the Game List banner. 148 func markDismissed(_ id: String) { 149 guard dismissedIDs.insert(id).inserted else { return } 150 } 151 152 /// Turns tips off entirely ("Never show me tips"). 153 func disable() { isDisabled = true } 154 155 /// Re-enables tips; the next cold launch surfaces the next undismissed one. 156 func enable() { isDisabled = false } 157 } 158 159 extension Tip { 160 /// Announcement id namespace for tips, so the Game List can tell a tip 161 /// banner apart from a real announcement when it's dismissed. 162 static func announcementID(for tipID: String) -> String { "tip-\(tipID)" } 163 164 /// Recovers the catalog tip id from a live banner's announcement id, or 165 /// `nil` when the announcement isn't a tip. 166 static func tipID(fromAnnouncementID announcementID: String) -> String? { 167 let prefix = "tip-" 168 guard announcementID.hasPrefix(prefix) else { return nil } 169 return String(announcementID.dropFirst(prefix.count)) 170 } 171 172 /// The live Game List banner for this tip: manually dismissable, lowest 173 /// severity (`.tip`) so any real announcement displaces it. 174 func liveAnnouncement() -> Announcement { 175 Announcement( 176 id: Self.announcementID(for: id), 177 scope: .global, 178 severity: .tip, 179 title: title, 180 body: body, 181 dismissal: .manual 182 ) 183 } 184 185 /// The read-only rendering for the Settings archive: same styling, no close 186 /// control. `.sticky` shows no ✕ and runs no auto-dismiss timer when the 187 /// banner is rendered directly rather than posted to an `AnnouncementCenter`. 188 func archiveAnnouncement() -> Announcement { 189 Announcement( 190 id: Self.announcementID(for: id), 191 scope: .global, 192 severity: .tip, 193 title: title, 194 body: body, 195 dismissal: .sticky 196 ) 197 } 198 }