crossmate

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

TipStore.swift (9941B)


      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 Invite Players 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. A different colour is selected each time for crossmates."
     53         ),
     54         Tip(
     55             id: "get-attention",
     56             title: "Announce Your Availability",
     57             body: "Nudge your crossmates 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         Tip(
     86             id: "partial-fill",
     87             title: "Break Solver's Block",
     88             body: "Choose Fill Quarter or Fill Half from the Hints menu to reveal random squares."
     89         ),
     90         Tip(
     91             id: "manage-blocked",
     92             title: "Block and Unblock Users",
     93             body: "Block using the Crossmates menu in the Game List. Unblock from your block list in Settings."
     94         ),
     95         Tip(
     96             id: "start-shared",
     97             title: "Start a Shared Puzzle",
     98             body: "Tap the Crossmates button in the Game List and tap a crossmate to start a shared puzzle with them."
     99         ),
    100         Tip(
    101             id: "share-your-win",
    102             title: "Share Your Solve",
    103             body: "Use the Share control when you've completed a puzzle to show everyone your solve."
    104         ),
    105     ]
    106 }
    107 
    108 /// Device-local record of which tips the user has dismissed, plus a global
    109 /// opt-out flag, persisted in `UserDefaults`. Modelled on `GameViewedStore`:
    110 /// never synced, purely local presentation state. `@Observable` so the
    111 /// `Settings → Tips` re-enable control reacts to the opt-out flag flipping.
    112 @MainActor
    113 @Observable
    114 final class TipStore {
    115     /// Cold launches that pass tip-free before the banner ever appears. A brand
    116     /// new user's first couple of visits to the Game List should be about the
    117     /// library, not onboarding chrome, so tips hold off until the launch after
    118     /// this many. Counting is cold-launch based (see `noteColdLaunch`), matching
    119     /// how tips are surfaced one-per-launch.
    120     static let launchesBeforeTips = 2
    121 
    122     /// When true the user chose "Never show me tips"; no tip is surfaced until
    123     /// they re-enable from Settings.
    124     var isDisabled: Bool {
    125         didSet { defaults.set(isDisabled, forKey: disabledKey) }
    126     }
    127 
    128     /// Catalog ids the user has dismissed from the Game List banner. Once
    129     /// dismissed, a tip never returns there (but stays in the Settings archive).
    130     private var dismissedIDs: Set<String> {
    131         didSet { defaults.set(Array(dismissedIDs), forKey: dismissedKey) }
    132     }
    133 
    134     /// Cold launches noted so far, persisted so the warm-up survives restarts.
    135     /// Capped just past `launchesBeforeTips` — once tips are due there's no
    136     /// reason to keep counting, so the stored value can't grow without bound.
    137     private var coldLaunchCount: Int {
    138         didSet { defaults.set(coldLaunchCount, forKey: coldLaunchCountKey) }
    139     }
    140 
    141     @ObservationIgnored private let defaults: UserDefaults
    142     @ObservationIgnored private let catalog: [Tip]
    143     @ObservationIgnored private let platform: TipPlatform
    144     @ObservationIgnored private let disabledKey = "tipsDisabled"
    145     @ObservationIgnored private let dismissedKey = "dismissedTipIDs"
    146     @ObservationIgnored private let coldLaunchCountKey = "tipColdLaunchCount"
    147 
    148     init(
    149         defaults: UserDefaults = .standard,
    150         catalog: [Tip] = TipCatalog.all,
    151         platform: TipPlatform = .current
    152     ) {
    153         self.defaults = defaults
    154         self.catalog = catalog
    155         self.platform = platform
    156         self.isDisabled = defaults.bool(forKey: disabledKey)
    157         self.dismissedIDs = Set(defaults.stringArray(forKey: dismissedKey) ?? [])
    158         self.coldLaunchCount = defaults.integer(forKey: coldLaunchCountKey)
    159     }
    160 
    161     /// Records that a fresh process has reached the Game List. Call once per
    162     /// cold launch, ahead of `currentTip()`. Stops incrementing once the warm-up
    163     /// is satisfied so the persisted count stays bounded.
    164     func noteColdLaunch() {
    165         guard coldLaunchCount <= Self.launchesBeforeTips else { return }
    166         coldLaunchCount += 1
    167     }
    168 
    169     /// Catalog tips that apply to this device, in order. Tips scoped to other
    170     /// platforms via `only` are filtered out, so neither the Game List banner
    171     /// nor the Settings archive surfaces them here.
    172     var visibleTips: [Tip] {
    173         catalog.filter { $0.only.contains(platform) }
    174     }
    175 
    176     /// The tip to surface now: the first applicable tip the user hasn't
    177     /// dismissed, or `nil` when tips are disabled, still within the launch
    178     /// warm-up (`launchesBeforeTips`), or every one has been seen.
    179     func currentTip() -> Tip? {
    180         guard !isDisabled else { return nil }
    181         guard coldLaunchCount > Self.launchesBeforeTips else { return nil }
    182         return firstUndismissedTip()
    183     }
    184 
    185     /// The first applicable tip the user hasn't dismissed, regardless of the
    186     /// disabled flag. This is the tip currently posted to the banner (a cold
    187     /// launch posts it before any toggle could disable tips), so turning tips
    188     /// off needs it to know which announcement to clear.
    189     func firstUndismissedTip() -> Tip? {
    190         visibleTips.first { !dismissedIDs.contains($0.id) }
    191     }
    192 
    193     /// Records that `id` was dismissed from the Game List banner.
    194     func markDismissed(_ id: String) {
    195         guard dismissedIDs.insert(id).inserted else { return }
    196     }
    197 
    198     /// Turns tips off entirely ("Never show me tips").
    199     func disable() { isDisabled = true }
    200 
    201     /// Re-enables tips; the next cold launch surfaces the next undismissed one.
    202     func enable() { isDisabled = false }
    203 }
    204 
    205 extension Tip {
    206     /// Announcement id namespace for tips, so the Game List can tell a tip
    207     /// banner apart from a real announcement when it's dismissed.
    208     static func announcementID(for tipID: String) -> String { "tip-\(tipID)" }
    209 
    210     /// Recovers the catalog tip id from a live banner's announcement id, or
    211     /// `nil` when the announcement isn't a tip.
    212     static func tipID(fromAnnouncementID announcementID: String) -> String? {
    213         let prefix = "tip-"
    214         guard announcementID.hasPrefix(prefix) else { return nil }
    215         return String(announcementID.dropFirst(prefix.count))
    216     }
    217 
    218     /// The live Game List banner for this tip: manually dismissable, lowest
    219     /// severity (`.tip`) so any real announcement displaces it.
    220     func liveAnnouncement() -> Announcement {
    221         Announcement(
    222             id: Self.announcementID(for: id),
    223             scope: .global,
    224             severity: .tip,
    225             title: title,
    226             body: body,
    227             dismissal: .manual
    228         )
    229     }
    230 
    231     /// The read-only rendering for the Settings archive: same styling, no close
    232     /// control. `.sticky` shows no ✕ and runs no auto-dismiss timer when the
    233     /// banner is rendered directly rather than posted to an `AnnouncementCenter`.
    234     func archiveAnnouncement() -> Announcement {
    235         Announcement(
    236             id: Self.announcementID(for: id),
    237             scope: .global,
    238             severity: .tip,
    239             title: title,
    240             body: body,
    241             dismissal: .sticky
    242         )
    243     }
    244 }