crossmate

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

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 }