crossmate

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

commit 0e1bb7f2bc426325f7673be8de3337dabf88a073
parent 8b2a2c43b151e8318b2229566a1978e47272335d
Author: Michael Camilleri <[email protected]>
Date:   Sat, 20 Jun 2026 12:48:41 +0900

Add onboarding tips

Crossmate had no lightweight way to introduce new players to what it can
do. This commit adds a small catalogue of tips that surface one at a
time in the Game List announcement banner, one per cold launch, so a
returning player drifts through them over several sessions rather than
meeting them all at once.

Tips ride the existing AnnouncementCenter as a new, lowest-severity tip
class: a real status message always displaces a tip and the tip returns
once that clears, and the puzzle header never shows one — tips are a
Game List affordance only. Dismissing a tip records it so it never comes
back and offers a 'Never Show Tips' capsule in its place. TipStore holds
both the dismissed set and that opt-out flag in UserDefaults,
device-local and never synced. A tip can be scoped to a device family
with `only:`.

The announcement banner now defaults to the inset-grouped section fill
and carries a little more vertical padding, with the puzzle header
keeping the frosted material it had.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 12++++++++++++
MCrossmate/CrossmateApp.swift | 1+
ACrossmate/Models/TipStore.swift | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Services/AnnouncementCenter.swift | 9+++++++--
MCrossmate/Services/AppServices.swift | 14++++++++++++++
MCrossmate/Views/Components/AnnouncementBanner.swift | 31++++++++++++++++++++++---------
MCrossmate/Views/GameList/GameListView.swift | 35++++++++++++++++++++++++++++++++++-
MCrossmate/Views/Puzzle/PuzzleHeader.swift | 5++++-
MCrossmate/Views/Settings/SettingsView.swift | 6++++++
ACrossmate/Views/Settings/TipsArchive.swift | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/AnnouncementCenterTests.swift | 27+++++++++++++++++++++++++++
ATests/Unit/TipStoreTests.swift | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 491 insertions(+), 13 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -54,10 +54,12 @@ 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */; }; 351CB23C537BAB61863D95F6 /* PuzzleNotificationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7CEB8980A9664BAAA5D196 /* PuzzleNotificationText.swift */; }; 35777D908A7D062730A18EF9 /* RecordEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF436B410916399336AC106 /* RecordEditorView.swift */; }; + 35D97436772257DAD3936ECB /* TipStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D4A76B233E16B7C5A248EB7 /* TipStore.swift */; }; 36E2AAF1EE1314E13477EE85 /* NicknameDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3111803C8FFFB0C839217482 /* NicknameDirectory.swift */; }; 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */; }; 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; }; 3C54AE4AA04342CCF5705B20 /* PlayerNamePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */; }; + 3C54B672A9FCA98C0A304470 /* TipsArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DC6394CB0F5B85C083FAC7 /* TipsArchive.swift */; }; 41290C86E72D6567C43C31B7 /* ShareLinkShortenerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 057F2B8B8A894D08BB801219 /* ShareLinkShortenerTests.swift */; }; 43E311FBD68B7D35A4D29743 /* PushPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2C9D3E7FCE2D42C5B7E3856 /* PushPayload.swift */; }; 449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */; }; @@ -122,6 +124,7 @@ 978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */; }; 98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; }; 9AACF424992AE45FD7937064 /* GameStoreCompletionLockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E230B327585E1E3A2921C92 /* GameStoreCompletionLockTests.swift */; }; + 9AD5700398B1C1F29A3A75F6 /* TipStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D6991C1EBAB2C64D9DF669 /* TipStoreTests.swift */; }; 9AD8936D94FD676B23DFBB77 /* RecentChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = 605CA0FC7AF069CE3A3B38C1 /* RecentChanges.swift */; }; 9C52C48DB4996D5C83DEC144 /* PuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57B1734CF731C2E405A39159 /* PuzzleView.swift */; }; 9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61E2582D94B1E6EC67136 /* XDFileType.swift */; }; @@ -346,6 +349,7 @@ 8AEFE2C7623A899BAABD85F4 /* MarketingPuzzleScreenshotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketingPuzzleScreenshotView.swift; sourceTree = "<group>"; }; 8C040D5EBC73B1ED47C2C9D4 /* SessionPushPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPushPlannerTests.swift; sourceTree = "<group>"; }; 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCursorStore.swift; sourceTree = "<group>"; }; + 8D4A76B233E16B7C5A248EB7 /* TipStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipStore.swift; sourceTree = "<group>"; }; 8FDE03B4A77A8095ED2C23AB /* EngagementCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementCoordinator.swift; sourceTree = "<group>"; }; 92168360625ECDD36FF50EE8 /* NicknameDirectoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknameDirectoryTests.swift; sourceTree = "<group>"; }; 927186458ED03FD0C5660765 /* CrossmateModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CrossmateModel.xcdatamodel; sourceTree = "<group>"; }; @@ -365,6 +369,7 @@ A8C18E9B47668E008BE4CF86 /* Archive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Archive.swift; sourceTree = "<group>"; }; A8CA0FC750259EB1D762B0EE /* OpenPuzzleBannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenPuzzleBannerTests.swift; sourceTree = "<group>"; }; A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoneOrphaningTests.swift; sourceTree = "<group>"; }; + A9DC6394CB0F5B85C083FAC7 /* TipsArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipsArchive.swift; sourceTree = "<group>"; }; ACC295195602B3DDF7BB3895 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; }; ADBA3FB1334DB816E62B7D9B /* PuzzleHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleHeader.swift; sourceTree = "<group>"; }; AF3B7E191D571FD800A4D719 /* LastUpdatedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastUpdatedView.swift; sourceTree = "<group>"; }; @@ -392,6 +397,7 @@ C06E2CC3A77CB306BD2DF867 /* LogScrubberTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogScrubberTests.swift; sourceTree = "<group>"; }; C2C9D3E7FCE2D42C5B7E3856 /* PushPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPayload.swift; sourceTree = "<group>"; }; C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverterTests.swift; sourceTree = "<group>"; }; + C8D6991C1EBAB2C64D9DF669 /* TipStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipStoreTests.swift; sourceTree = "<group>"; }; C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationTextTests.swift; sourceTree = "<group>"; }; CA49DA9E0ADEB11A920787FA /* SessionPushPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPushPlanner.swift; sourceTree = "<group>"; }; CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = "<group>"; }; @@ -501,6 +507,7 @@ BCACEED6A9235EC6221F4F66 /* DiagnosticsView.swift */, 3EF436B410916399336AC106 /* RecordEditorView.swift */, 7C6AB016CA4E2FC69A0E6A4F /* SettingsView.swift */, + A9DC6394CB0F5B85C083FAC7 /* TipsArchive.swift */, ); path = Settings; sourceTree = "<group>"; @@ -553,6 +560,7 @@ 5990D989AD745211A18848E4 /* ShareLinkRouteTests.swift */, 057F2B8B8A894D08BB801219 /* ShareLinkShortenerTests.swift */, 0C190EA5717C291B3F2AE46C /* SyncMonitorTests.swift */, + C8D6991C1EBAB2C64D9DF669 /* TipStoreTests.swift */, 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */, ABB371EF2574E95782CB05FD /* Sync */, ); @@ -583,6 +591,7 @@ E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */, 50EE8A159CC553623F6F7DE4 /* ReplayControls.swift */, DB851649DE78AAAC5A928C52 /* Square.swift */, + 8D4A76B233E16B7C5A248EB7 /* TipStore.swift */, B9031A1574C21866940F6A2C /* XD.swift */, EAC61E2582D94B1E6EC67136 /* XDFileType.swift */, F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */, @@ -982,6 +991,7 @@ BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */, 025377AF80D45967CE910423 /* SyncMonitorTests.swift in Sources */, 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */, + 9AD5700398B1C1F29A3A75F6 /* TipStoreTests.swift in Sources */, 31F2B6A61ED352C7D800149F /* XDAcceptTests.swift in Sources */, 9582AA583F5EA008FFC82B64 /* ZoneOrphaningTests.swift in Sources */, ); @@ -1110,6 +1120,8 @@ DDC7994B951A3A7B836B36F6 /* SuccessPanel.swift in Sources */, 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */, F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */, + 35D97436772257DAD3936ECB /* TipStore.swift in Sources */, + 3C54B672A9FCA98C0A304470 /* TipsArchive.swift in Sources */, 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */, 9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */, ); diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -29,6 +29,7 @@ struct CrossmateApp: App { .environment(services.driveMonitor) .environment(services.inputMonitor) .environment(services.announcements) + .environment(services.tips) .environment(services.syncMonitor) .environment(services.eventLog) .environment(\.syncEngine, services.syncEngine) diff --git a/Crossmate/Models/TipStore.swift b/Crossmate/Models/TipStore.swift @@ -0,0 +1,198 @@ +import Foundation +import Observation +import UIKit + +/// A device family a tip can be scoped to. The app runs on iPhone and iPad; a +/// tip lists the platforms it should appear on (its `only` set), defaulting to +/// all of them. +enum TipPlatform: Hashable, CaseIterable { + case iPhone + case iPad + + /// Every platform — the default `only` value, i.e. "show everywhere". + static let all: Set<TipPlatform> = Set(allCases) + + /// The platform of the device the app is running on. + @MainActor static var current: TipPlatform { + UIDevice.current.userInterfaceIdiom == .pad ? .iPad : .iPhone + } +} + +/// One onboarding tip shown in the Game List announcement banner and listed in +/// `Settings → Tips`. The catalog is ordered; `TipStore` walks it in order, +/// surfacing the first tip the user has not yet dismissed. +struct Tip: Identifiable, Equatable { + let id: String + let title: String + let body: String + /// Platforms this tip appears on. Defaults to all of them; e.g. `[.iPad]` + /// makes it iPad-only. + var only: Set<TipPlatform> = TipPlatform.all +} + +enum TipCatalog { + /// Ordered tips. One surfaces per cold launch until each has been dismissed. + /// Body wraps to at most three lines in the banner (`AnnouncementBanner` + /// caps the body at `lineLimit(3)`), title to one. Scope a tip to a device + /// family with `only:` (e.g. the iPad-only hardware-keyboard tip). + static let all: [Tip] = [ + Tip( + id: "solve-together", + title: "Solve Together", + body: "Start a puzzle and choose Share Puzzle from the Players menu." + ), + Tip( + id: "import-puzzles", + title: "Import Puzzles", + body: "Download Across Lite or XD files in Safari or save to the Crossmate folder in iCloud Drive." + ), + Tip( + id: "connect-providers", + title: "Connect Providers", + body: "Add external providers in Settings. Connected providers appear when starting a new puzzle." + ), + Tip( + id: "pick-your-colour", + title: "Pick Your Colour", + body: "Choose your colour. Crossmate selects a colour each time for friends." + ), + Tip( + id: "get-attention", + title: "Get Attention", + body: "Send a nudge to the friends in a shared puzzle." + ), + Tip( + id: "be-notified", + title: "Be Notified", + body: "Adjust the notifications you want to see in Settings." + ), + Tip( + id: "take-a-hint", + title: "Take a Hint", + body: "Use the in-game Hints menu to check your letters or get an answer." + ), + Tip( + id: "undo-redo", + title: "Undo Mistakes", + body: "Use the overflow menu on the keyboard to access undo and redo." + ), + Tip( + id: "hardware-keyboard", + title: "Hardware Keyboard", + body: "Connect a keyboard to solve more swiftly.", + only: [.iPad] + ), + ] +} + +/// Device-local record of which tips the user has dismissed, plus a global +/// opt-out flag, persisted in `UserDefaults`. Modelled on `GameViewedStore`: +/// never synced, purely local presentation state. `@Observable` so the +/// `Settings → Tips` re-enable control reacts to the opt-out flag flipping. +@MainActor +@Observable +final class TipStore { + /// When true the user chose "Never show me tips"; no tip is surfaced until + /// they re-enable from Settings. + var isDisabled: Bool { + didSet { defaults.set(isDisabled, forKey: disabledKey) } + } + + /// Catalog ids the user has dismissed from the Game List banner. Once + /// dismissed, a tip never returns there (but stays in the Settings archive). + private var dismissedIDs: Set<String> { + didSet { defaults.set(Array(dismissedIDs), forKey: dismissedKey) } + } + + @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" + + init( + defaults: UserDefaults = .standard, + catalog: [Tip] = TipCatalog.all, + platform: TipPlatform = .current + ) { + self.defaults = defaults + self.catalog = catalog + self.platform = platform + self.isDisabled = defaults.bool(forKey: disabledKey) + self.dismissedIDs = Set(defaults.stringArray(forKey: dismissedKey) ?? []) + } + + /// Catalog tips that apply to this device, in order. Tips scoped to other + /// platforms via `only` are filtered out, so neither the Game List banner + /// nor the Settings archive surfaces them here. + var visibleTips: [Tip] { + catalog.filter { $0.only.contains(platform) } + } + + /// 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. + func currentTip() -> Tip? { + guard !isDisabled else { return nil } + return firstUndismissedTip() + } + + /// The first applicable tip the user hasn't dismissed, regardless of the + /// disabled flag. This is the tip currently posted to the banner (a cold + /// launch posts it before any toggle could disable tips), so turning tips + /// off needs it to know which announcement to clear. + func firstUndismissedTip() -> Tip? { + visibleTips.first { !dismissedIDs.contains($0.id) } + } + + /// Records that `id` was dismissed from the Game List banner. + func markDismissed(_ id: String) { + guard dismissedIDs.insert(id).inserted else { return } + } + + /// Turns tips off entirely ("Never show me tips"). + func disable() { isDisabled = true } + + /// Re-enables tips; the next cold launch surfaces the next undismissed one. + func enable() { isDisabled = false } +} + +extension Tip { + /// Announcement id namespace for tips, so the Game List can tell a tip + /// banner apart from a real announcement when it's dismissed. + static func announcementID(for tipID: String) -> String { "tip-\(tipID)" } + + /// Recovers the catalog tip id from a live banner's announcement id, or + /// `nil` when the announcement isn't a tip. + static func tipID(fromAnnouncementID announcementID: String) -> String? { + let prefix = "tip-" + guard announcementID.hasPrefix(prefix) else { return nil } + return String(announcementID.dropFirst(prefix.count)) + } + + /// The live Game List banner for this tip: manually dismissable, lowest + /// severity (`.tip`) so any real announcement displaces it. + func liveAnnouncement() -> Announcement { + Announcement( + id: Self.announcementID(for: id), + scope: .global, + severity: .tip, + title: title, + body: body, + dismissal: .manual + ) + } + + /// The read-only rendering for the Settings archive: same styling, no close + /// control. `.sticky` shows no ✕ and runs no auto-dismiss timer when the + /// banner is rendered directly rather than posted to an `AnnouncementCenter`. + func archiveAnnouncement() -> Announcement { + Announcement( + id: Self.announcementID(for: id), + scope: .global, + severity: .tip, + title: title, + body: body, + dismissal: .sticky + ) + } +} diff --git a/Crossmate/Services/AnnouncementCenter.swift b/Crossmate/Services/AnnouncementCenter.swift @@ -11,6 +11,9 @@ struct Announcement: Identifiable, Equatable, Sendable { /// pick-the-winner logic when two announcements compete for the same /// surface — higher-severity displaces lower. enum Severity: Int, Comparable, Sendable { + /// Lowest severity: onboarding tips. Ranked below `info` so a tip never + /// displaces a real status message and is itself displaced by one. + case tip case info case warning case error @@ -213,11 +216,13 @@ final class AnnouncementCenter { /// Topmost announcement to display in the puzzle header for `gameID`. /// Prefers game-scoped over global so a puzzle-relevant message isn't /// hidden behind an app-wide one; ties broken by severity, then by - /// createdAt (newest wins). + /// createdAt (newest wins). Tips are excluded entirely: they're a Game + /// List-only affordance and must never surface over the puzzle grid. func current(forGame gameID: UUID) -> Announcement? { let gameScoped = byId.values.filter { $0.scope == .game(gameID) } if let pick = pick(from: gameScoped) { return pick } - return currentGlobal() + let global = currentGlobal() + return global?.severity == .tip ? nil : global } /// Topmost global announcement (used by surfaces that have no specific diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -177,6 +177,10 @@ final class AppServices { /// Device-local record of when each game was last viewed; drives the /// "changed while you were away" cell borders. Never synced. let gameViewedStore: GameViewedStore + /// Device-local onboarding-tip state: which tips have been dismissed and + /// whether tips are turned off. Drives the Game List tip banner and the + /// Settings tips archive. Never synced. + let tips: TipStore let engagementStore: EngagementStore let cloudService: CloudService let importService: ImportService @@ -377,6 +381,7 @@ final class AppServices { self.cursorStore = cursorStore let gameViewedStore = GameViewedStore() self.gameViewedStore = gameViewedStore + self.tips = TipStore() let engagementStore = EngagementStore() self.engagementStore = engagementStore let onGameDeletedHandler = Self.makeOnGameDeleted( @@ -528,6 +533,15 @@ final class AppServices { guard !started else { return } started = true + // Surface one onboarding tip per cold launch. The in-memory + // 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. + if let tip = tips.currentTip() { + announcements.post(tip.liveAnnouncement()) + } + // Hydrate the persisted diagnostics history before live breadcrumbs // flow, so a log collected this morning still carries last night's // session. Ordering against startup notes is by timestamp, so a note diff --git a/Crossmate/Views/Components/AnnouncementBanner.swift b/Crossmate/Views/Components/AnnouncementBanner.swift @@ -9,16 +9,25 @@ struct AnnouncementBanner: View { /// (PuzzleView's fixed-height header slot); by default it hugs its text, /// which is what inline placements like the game list need. var fillsAvailableHeight = false + /// Whether to show the leading severity icon. The Settings tips archive + /// turns it off — the rail alone carries the tip styling there. + var showsIcon = true + /// Fill behind the banner. Defaults to the inset-grouped section fill so + /// banners read as solid cards on the grouped Game List and in the Settings + /// tips archive; override per surface if a different fill is wanted. + var background: AnyShapeStyle = AnyShapeStyle(Color(.secondarySystemGroupedBackground)) let onDismiss: (() -> Void)? var body: some View { let severityColor = tint(for: announcement.severity) HStack(alignment: .center, spacing: 8) { - Image(systemName: iconName(for: announcement.severity)) - .font(.footnote.weight(.semibold)) - .foregroundStyle(severityColor) - .frame(width: 24) - .accessibilityHidden(true) + if showsIcon { + Image(systemName: iconName(for: announcement.severity)) + .font(.footnote.weight(.semibold)) + .foregroundStyle(severityColor) + .frame(width: 24) + .accessibilityHidden(true) + } VStack(alignment: .leading, spacing: 3) { if let title = announcement.title, !title.isEmpty { @@ -30,7 +39,7 @@ struct AnnouncementBanner: View { .font(.subheadline) .foregroundStyle(.primary) .multilineTextAlignment(.leading) - .lineLimit(2) + .lineLimit(3) } .frame(maxWidth: .infinity, alignment: .leading) @@ -48,14 +57,16 @@ struct AnnouncementBanner: View { } } .padding(.horizontal, 10) - .padding(.vertical, 6) - .padding(.leading, 4) + .padding(.vertical, 10) + // Without the icon the text would otherwise crowd the severity rail; + // give it a little extra breathing room on the leading edge. + .padding(.leading, showsIcon ? 4 : 12) .frame( maxWidth: .infinity, maxHeight: fillsAvailableHeight ? .infinity : nil, alignment: .leading ) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .background(background, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) // The severity rail is drawn as an overlay rather than laid out as an // HStack child: a width-constrained `Rectangle` in the HStack is // greedy for height, which made the whole banner expand to fill @@ -84,6 +95,7 @@ struct AnnouncementBanner: View { /// SF Symbol that leads the banner text, one per severity. private func iconName(for severity: Announcement.Severity) -> String { switch severity { + case .tip: return "lightbulb" case .info: return "bell" case .warning: return "exclamationmark.triangle" case .error: return "xmark.octagon" @@ -92,6 +104,7 @@ struct AnnouncementBanner: View { private func tint(for severity: Announcement.Severity) -> Color { switch severity { + case .tip: return .yellow case .info: return .blue case .warning: return .orange case .error: return .red diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift @@ -39,6 +39,7 @@ struct GameListView: View { @Environment(\.sendResignPings) private var sendResignPings @Environment(PlayerPreferences.self) private var preferences @Environment(AnnouncementCenter.self) private var announcements + @Environment(TipStore.self) private var tips @State private var acceptingInviteID: NSManagedObjectID? @State private var blockTarget: InviteEntity? @@ -53,6 +54,10 @@ struct GameListView: View { @State private var nameDraft = "" @State private var summaryCache = GameSummaryCache() @State private var completedVisibleCount = completedPageSize + /// Shows the "Never show me tips" opt-out in the banner slot for a few + /// seconds after a tip is dismissed; cleared by the timer or by tapping it. + @State private var showTipOptOut = false + @State private var tipOptOutHideTask: Task<Void, Never>? private static let completedPageSize = 7 @@ -61,16 +66,28 @@ struct GameListView: View { VStack(spacing: 0) { if let announcement = announcements.currentGlobal() { AnnouncementBanner(announcement: announcement) { - announcements.dismiss(id: announcement.id) + dismissAnnouncement(announcement) } .padding(.horizontal) .padding(.top, 8) .transition(.move(edge: .top).combined(with: .opacity)) + } else if showTipOptOut { + Button("Never Show Tips") { + tipOptOutHideTask?.cancel() + tips.disable() + showTipOptOut = false + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .controlSize(.small) + .padding(.top, 8) + .transition(.opacity) } content(usesRoomierType: usesRoomierType(for: geometry.size)) } .background(Color(.systemGroupedBackground)) .animation(.easeInOut(duration: 0.3), value: announcements.currentGlobal()) + .animation(.easeInOut(duration: 0.3), value: showTipOptOut) } .navigationTitle("") .navigationBarTitleDisplayMode(.inline) @@ -222,6 +239,22 @@ struct GameListView: View { } } + /// Dismisses the banner's announcement. For a tip, also records it as + /// dismissed so it never returns, and surfaces the "Never show me tips" + /// opt-out in its place for a few seconds. + private func dismissAnnouncement(_ announcement: Announcement) { + announcements.dismiss(id: announcement.id) + guard let tipID = Tip.tipID(fromAnnouncementID: announcement.id) else { return } + tips.markDismissed(tipID) + showTipOptOut = true + tipOptOutHideTask?.cancel() + tipOptOutHideTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(6)) + guard !Task.isCancelled else { return } + showTipOptOut = false + } + } + @ViewBuilder private func content(usesRoomierType: Bool) -> some View { let summaries = games.compactMap { diff --git a/Crossmate/Views/Puzzle/PuzzleHeader.swift b/Crossmate/Views/Puzzle/PuzzleHeader.swift @@ -95,7 +95,10 @@ struct PuzzleHeader: View { if let announcement = visibleAnnouncement { AnnouncementBanner( announcement: announcement, - fillsAvailableHeight: true + fillsAvailableHeight: true, + // Over the puzzle grid the frosted material reads better than + // the solid grouped fill the Game List and archive use. + background: AnyShapeStyle(.regularMaterial) ) { announcements.dismiss(id: announcement.id) } diff --git a/Crossmate/Views/Settings/SettingsView.swift b/Crossmate/Views/Settings/SettingsView.swift @@ -106,6 +106,12 @@ struct SettingsView: View { } Section { + NavigationLink("Tips") { + TipsArchive() + } + } + + Section { NavigationLink("About") { AboutView() } diff --git a/Crossmate/Views/Settings/TipsArchive.swift b/Crossmate/Views/Settings/TipsArchive.swift @@ -0,0 +1,53 @@ +import SwiftUI + +/// Read-only list of every tip, reached from `Settings → Tips`. Each tip is +/// rendered with the same `AnnouncementBanner` chrome it gets on the Game List, +/// stacked in a plain scroll view so the banners float on the background rather +/// than nesting inside grouped `Form` rows. The archive shows every tip +/// regardless of whether it has been dismissed on the Game List, and offers a +/// way back in if the user previously chose "Never show me tips". +struct TipsArchive: View { + @Environment(TipStore.self) private var tips + @Environment(AnnouncementCenter.self) private var announcements + + var body: some View { + ScrollView { + LazyVStack(spacing: 12) { + Toggle("Show Tips", isOn: Binding( + get: { !tips.isDisabled }, + set: { tips.isDisabled = !$0 } + )) + .padding(.horizontal, 16) + .padding(.vertical, 11) + // The inset-grouped "section" fill, so the toggle reads like a + // Settings row rather than floating on the bare background. + .background( + Color(.secondarySystemGroupedBackground), + in: RoundedRectangle(cornerRadius: 10, style: .continuous) + ) + .padding(.bottom, 4) + + ForEach(tips.visibleTips) { tip in + AnnouncementBanner( + announcement: tip.archiveAnnouncement(), + showsIcon: false, + onDismiss: nil + ) + } + } + .padding() + } + .background(Color(.systemGroupedBackground)) + .navigationTitle("Tips") + .navigationBarTitleDisplayMode(.inline) + .onDisappear { + // Leaving the archive with tips turned off clears any tip still + // showing on the Game List. This only un-displays it — the tip + // isn't marked individually dismissed, so re-enabling later + // surfaces it again on the next cold launch. Toggling off then back + // on before leaving leaves the banner untouched. + guard tips.isDisabled, let tip = tips.firstUndismissedTip() else { return } + announcements.dismiss(id: Tip.announcementID(for: tip.id)) + } + } +} diff --git a/Tests/Unit/AnnouncementCenterTests.swift b/Tests/Unit/AnnouncementCenterTests.swift @@ -88,6 +88,33 @@ struct AnnouncementCenterTests { #expect(center.current(forGame: gameID)?.body == "e") } + @Test("A tip yields the slot to any real announcement, then returns when it clears") + func tipIsLowestPriority() { + let center = AnnouncementCenter() + center.post(makeAnnouncement(id: "tip", severity: .tip, body: "tip")) + #expect(center.currentGlobal()?.body == "tip") + // A real status message of any higher severity displaces the tip. + center.post(makeAnnouncement(id: "info", severity: .info, body: "info")) + #expect(center.currentGlobal()?.body == "info") + // Once the real message clears, the tip resurfaces. + center.dismiss(id: "info") + #expect(center.currentGlobal()?.body == "tip") + } + + @Test("A global tip shows on the game list but never in the puzzle header") + func tipIsGameListOnly() { + let center = AnnouncementCenter() + let gameID = UUID() + center.post(makeAnnouncement(id: "tip", severity: .tip, body: "tip")) + // The game list surface still sees it. + #expect(center.currentGlobal()?.body == "tip") + // The puzzle header does not fall back to a tip. + #expect(center.current(forGame: gameID) == nil) + // A real global announcement still surfaces in the header as before. + center.post(makeAnnouncement(id: "err", severity: .error, body: "err")) + #expect(center.current(forGame: gameID)?.body == "err") + } + @Test(".transient announcements auto-dismiss after their delay") func transientAutoDismisses() async throws { let manualSleep = ManualDebounceSleep() diff --git a/Tests/Unit/TipStoreTests.swift b/Tests/Unit/TipStoreTests.swift @@ -0,0 +1,113 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("TipStore") +@MainActor +struct TipStoreTests { + + private let catalog = [ + Tip(id: "one", title: "One", body: "First"), + Tip(id: "two", title: "Two", body: "Second"), + Tip(id: "three", title: "Three", body: "Third"), + ] + + private func makeStore( + defaults: UserDefaults? = nil + ) -> (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) + } + + @Test("currentTip returns the first catalog tip by default") + func firstTipInitially() { + let (store, _) = makeStore() + #expect(store.currentTip()?.id == "one") + } + + @Test("Dismissing a tip advances to the next undismissed one") + func dismissAdvances() { + let (store, _) = makeStore() + store.markDismissed("one") + #expect(store.currentTip()?.id == "two") + store.markDismissed("two") + #expect(store.currentTip()?.id == "three") + } + + @Test("Once every tip is dismissed there is nothing to surface") + func allDismissed() { + let (store, _) = makeStore() + for tip in catalog { store.markDismissed(tip.id) } + #expect(store.currentTip() == nil) + } + + @Test("Disabling tips suppresses the banner regardless of dismissals") + func disableSuppresses() { + let (store, _) = makeStore() + store.disable() + #expect(store.isDisabled) + #expect(store.currentTip() == nil) + // Re-enabling brings back the next undismissed tip. + store.enable() + #expect(!store.isDisabled) + #expect(store.currentTip()?.id == "one") + } + + @Test("Dismissals and the opt-out flag persist across instances") + func persistsAcrossInstances() { + let (store, defaults) = makeStore() + store.markDismissed("one") + store.disable() + + let (reloaded, _) = makeStore(defaults: defaults) + #expect(reloaded.isDisabled) + reloaded.enable() + #expect(reloaded.currentTip()?.id == "two") + } + + @Test("Tips scoped to other platforms are filtered out") + func platformScoping() { + let catalog = [ + Tip(id: "both", title: "Both", body: "Everywhere"), + Tip(id: "ipad", title: "iPad", body: "iPad only", only: [.iPad]), + 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) + #expect(onPhone.visibleTips.map(\.id) == ["both", "iphone"]) + // The first applicable tip skips the iPad-only one. + #expect(onPhone.currentTip()?.id == "both") + onPhone.markDismissed("both") + #expect(onPhone.currentTip()?.id == "iphone") + + let onPad = TipStore(defaults: defaults, catalog: catalog, platform: .iPad) + #expect(onPad.visibleTips.map(\.id) == ["both", "ipad"]) + } + + @Test("Tip announcement ids round-trip so the banner can recognise a tip") + func announcementIDRoundTrips() { + let id = Tip.announcementID(for: "welcome") + #expect(Tip.tipID(fromAnnouncementID: id) == "welcome") + // A non-tip announcement id is not mistaken for a tip. + #expect(Tip.tipID(fromAnnouncementID: "reset-database-error") == nil) + } + + @Test("A tip's live banner is a lowest-severity, manually dismissable global") + func liveAnnouncementShape() { + let tip = catalog[0] + let announcement = tip.liveAnnouncement() + #expect(announcement.scope == .global) + #expect(announcement.severity == .tip) + #expect(announcement.dismissal == .manual) + #expect(announcement.title == "One") + #expect(announcement.body == "First") + } + + @Test("A tip's archive banner has no close control") + func archiveAnnouncementHasNoCloseControl() { + // The banner shows a ✕ only for `.manual`; the archive uses `.sticky`. + #expect(catalog[0].archiveAnnouncement().dismissal == .sticky) + } +}