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:
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)
+ }
+}