commit f41ff081d57e16fe5deaa74632f348d044a40829
parent 12646b23c31a4c4d39bd2d1c7dd11055771f2f62
Author: Michael Camilleri <[email protected]>
Date: Thu, 9 Jul 2026 13:37:07 +0900
Consolidate Puzzle Picker sources
This commit removes the separate Debug source and presents its fixtures
as a bundle when debug mode is enabled. The final tab is a generic
External, with a centred, bordered provider menu above the calendar so
additional providers can join the picker without adding more top-level
segments.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
10 files changed, 125 insertions(+), 49 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -35,6 +35,7 @@
18CA29E61C4F1471A455FEC8 /* FriendEncryptionKeyDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833AAFBA0094BAB27E13FE29 /* FriendEncryptionKeyDirectory.swift */; };
18D5BB584DBF92A2EC580AEA /* NotificationNavigationBrokerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */; };
197DDF45C36B9570BB9AE4B5 /* AuthorIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */; };
+ 1989FEF3DE02BEA414B6E9ED /* ExternalBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFA63B0051B4E6F4FC4B132A /* ExternalBrowseView.swift */; };
1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BDD06460A76D4AF31077732 /* InputMonitor.swift */; };
1A1A8A9AB36D02E2A5A9ED28 /* GameViewedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9AE0F26E602A9246F5C6ABF /* GameViewedStore.swift */; };
1AAFF86B40CBBFF1EC9ADF9F /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B1F07B5DDE2A8B49B28392A /* GridThumbnailView.swift */; };
@@ -78,6 +79,7 @@
4E14BB5D6F96D178373ED55A /* PeerChangeLedger.swift in Sources */ = {isa = PBXBuildFile; fileRef = B427285F8D6BE35025591BFA /* PeerChangeLedger.swift */; };
4F1A93404828EDBDBBF86716 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C6AB016CA4E2FC69A0E6A4F /* SettingsView.swift */; };
503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; };
+ 5082CCB75939E1BC6E9DC03A /* AppDefaultsMigratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F4D2E1504784E738D2B33A /* AppDefaultsMigratorTests.swift */; };
50C02D37A41D55CFA5D307E2 /* NYTPuzzleUpgraderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34489D0864DF76AF436E391 /* NYTPuzzleUpgraderTests.swift */; };
51E6F7F2FC52C2AA87B9DB45 /* PeerPresenceGraceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08E8592B1CB1336E63498706 /* PeerPresenceGraceTests.swift */; };
521E877D28502B917C89B66D /* AccountPushCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B166E87D13C41C4D3575169 /* AccountPushCoordinatorTests.swift */; };
@@ -98,6 +100,7 @@
6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; };
6BD5C5B6BED06651312E2D01 /* MailComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F8AAA9540C80F787A1465C0 /* MailComposeView.swift */; };
6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; };
+ 6BFF94778327922994DBBA12 /* AppDefaultsMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20E6A7786CDCCF227B0E7DD9 /* AppDefaultsMigrator.swift */; };
6C091D30AAC9F63B7CE6FB58 /* AnnouncementCenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */; };
6D2AF361587E43D807BA212F /* NYTLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1747D4DCB4BCC831069BBE07 /* NYTLoginView.swift */; };
6E36ED34ACF047BABB3E2D69 /* RecentChangesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80B377D237AC14B9856579E1 /* RecentChangesTests.swift */; };
@@ -293,6 +296,7 @@
1B7539E0AD285C5A3AC3DDA2 /* GameArchiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameArchiver.swift; sourceTree = "<group>"; };
1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementCenter.swift; sourceTree = "<group>"; };
20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; };
+ 20E6A7786CDCCF227B0E7DD9 /* AppDefaultsMigrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDefaultsMigrator.swift; sourceTree = "<group>"; };
23A7390848B1943C80142934 /* PuzzleGridStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleGridStats.swift; sourceTree = "<group>"; };
23FCFFF1C2C7E909DFD8FC43 /* PlayerColorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColorTests.swift; sourceTree = "<group>"; };
24A4B5C8EC4A46906C07F819 /* GameEntity+ContentKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GameEntity+ContentKey.swift"; sourceTree = "<group>"; };
@@ -398,6 +402,7 @@
978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementCenterTests.swift; sourceTree = "<group>"; };
98E68FD021EF8ED50BC706EE /* CloudContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudContainer.swift; sourceTree = "<group>"; };
9998739ED0875A17271B7899 /* AppServicesAnnouncementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServicesAnnouncementTests.swift; sourceTree = "<group>"; };
+ 99F4D2E1504784E738D2B33A /* AppDefaultsMigratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDefaultsMigratorTests.swift; sourceTree = "<group>"; };
9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncState+Helpers.swift"; sourceTree = "<group>"; };
9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStorePushAddressTests.swift; sourceTree = "<group>"; };
9AF6157D97271205626E207C /* MovesUpdaterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesUpdaterTests.swift; sourceTree = "<group>"; };
@@ -415,6 +420,7 @@
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>"; };
+ AFA63B0051B4E6F4FC4B132A /* ExternalBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalBrowseView.swift; sourceTree = "<group>"; };
AFFB5B2EFBB62B7021AC2FC2 /* TimeLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeLog.swift; sourceTree = "<group>"; };
B024B2FFB11E51E9724BBE23 /* CompactSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactSlider.swift; sourceTree = "<group>"; };
B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleFetcher.swift; sourceTree = "<group>"; };
@@ -570,6 +576,7 @@
children = (
4B166E87D13C41C4D3575169 /* AccountPushCoordinatorTests.swift */,
978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */,
+ 99F4D2E1504784E738D2B33A /* AppDefaultsMigratorTests.swift */,
B9EA9CF96312BFF5340CE2A7 /* ArchiveTests.swift */,
3059A4C23D040E0D1F258B92 /* CellAccessibilityDescriberTests.swift */,
60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */,
@@ -632,6 +639,7 @@
41DB2417FF67A47FE6890256 /* Models */ = {
isa = PBXGroup;
children = (
+ 20E6A7786CDCCF227B0E7DD9 /* AppDefaultsMigrator.swift */,
B135C285570F91181595B405 /* CellMark.swift */,
0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */,
B09D52DB46731E92C3E9297C /* EngagementStore.swift */,
@@ -778,6 +786,7 @@
children = (
7023E506D777DB80B18A7DB5 /* BundledBrowseView.swift */,
CF20BDF7FF6423BA4FD911D5 /* CalendarDayCell.swift */,
+ AFA63B0051B4E6F4FC4B132A /* ExternalBrowseView.swift */,
2A832061C19BA0F073617CA2 /* ImportedBrowseView.swift */,
09C81EFA0B7776CB9713CD63 /* NewGameSheet.swift */,
3413F8755236FC0578AF8109 /* NYTBrowseView.swift */,
@@ -1009,6 +1018,7 @@
files = (
521E877D28502B917C89B66D /* AccountPushCoordinatorTests.swift in Sources */,
6C091D30AAC9F63B7CE6FB58 /* AnnouncementCenterTests.swift in Sources */,
+ 5082CCB75939E1BC6E9DC03A /* AppDefaultsMigratorTests.swift in Sources */,
D519F54F8CE0BD53D9C6144C /* AppServicesAnnouncementTests.swift in Sources */,
DB098F40C6950E29B4BF10A7 /* ArchiveTests.swift in Sources */,
A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */,
@@ -1093,6 +1103,7 @@
A87E5E615559B6461B1C3F94 /* AnnouncementBanner.swift in Sources */,
5EFCD28B3B682DCCF38068D6 /* AnnouncementCenter.swift in Sources */,
AE92B185FB52032AEB9421A1 /* AppActions.swift in Sources */,
+ 6BFF94778327922994DBBA12 /* AppDefaultsMigrator.swift in Sources */,
78802AFDF6273231781CC0DC /* AppServices.swift in Sources */,
A65F99414F8CF6704567BB07 /* Archive.swift in Sources */,
197DDF45C36B9570BB9AE4B5 /* AuthorIdentity.swift in Sources */,
@@ -1124,6 +1135,7 @@
D240BF6498A9148855DB7734 /* EngagementLifecycle.swift in Sources */,
648EA338E7718079EA354D7D /* EngagementMessageAuthenticator.swift in Sources */,
06AE6DF7AA3274480C591E47 /* EngagementStore.swift in Sources */,
+ 1989FEF3DE02BEA414B6E9ED /* ExternalBrowseView.swift in Sources */,
BD317ECC09C9099AC29B8C5D /* FriendAvatarView.swift in Sources */,
C8ACF431021E7BEE61A99153 /* FriendController.swift in Sources */,
7E6ED856CE78A774B061CA60 /* FriendEncryptionKeyDirectory.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -9,6 +9,7 @@ struct CrossmateApp: App {
@State private var services: AppServices
init() {
+ AppDefaultsMigrator.run()
self._services = State(initialValue: AppServices())
}
diff --git a/Crossmate/Models/AppDefaultsMigrator.swift b/Crossmate/Models/AppDefaultsMigrator.swift
@@ -0,0 +1,9 @@
+import Foundation
+
+enum AppDefaultsMigrator {
+ static func run(defaults: UserDefaults = .standard) {
+ if defaults.string(forKey: "lastPuzzleSource") == "nyt" {
+ defaults.set(PuzzleSource.external.rawValue, forKey: "lastPuzzleSource")
+ }
+ }
+}
diff --git a/Crossmate/Models/PuzzleCatalog.swift b/Crossmate/Models/PuzzleCatalog.swift
@@ -31,7 +31,7 @@ struct PuzzleCatalog {
let puzzles: [Entry]
}
- /// Bundle id of the debug-only fixture set, kept out of `bundles()`.
+ /// Bundle id of the debug-only fixture set.
private static let debugBundleID = "debug"
/// Manifests are immutable for the life of the process, so the catalog is
@@ -39,15 +39,10 @@ struct PuzzleCatalog {
/// freely without paying a re-read each time.
private static let allBundles: [PuzzleBundle] = loadAllBundles()
- /// The puzzle bundles shown in the picker: every bundle under `Puzzles/`
- /// except the debug fixtures.
- static func bundles() -> [PuzzleBundle] {
- allBundles.filter { $0.id != debugBundleID }
- }
-
- /// The debug-only fixture puzzles, surfaced only in debug builds.
- static func debugPuzzles() -> [Entry] {
- allBundles.first { $0.id == debugBundleID }?.puzzles ?? []
+ /// The puzzle bundles shown in the picker. Debug fixtures join the regular
+ /// bundles when the app's debug mode is enabled.
+ static func bundles(includeDebug: Bool = false) -> [PuzzleBundle] {
+ includeDebug ? allBundles : allBundles.filter { $0.id != debugBundleID }
}
static func source(
diff --git a/Crossmate/Models/PuzzleSource.swift b/Crossmate/Models/PuzzleSource.swift
@@ -2,18 +2,16 @@ import Foundation
enum PuzzleSource: String, CaseIterable, Identifiable {
case bundles
- case debug
+ case external
case imported
- case nyt
var id: String { rawValue }
var title: String {
switch self {
case .bundles: "Bundles"
- case .debug: "Debug"
+ case .external: "External"
case .imported: "Imported"
- case .nyt: "NYT"
}
}
}
diff --git a/Crossmate/Views/Browse/BundledBrowseView.swift b/Crossmate/Views/Browse/BundledBrowseView.swift
@@ -3,8 +3,10 @@ import SwiftUI
struct BundledBrowseView: View {
let onSelected: (String) -> Void
+ @AppStorage("debugMode") private var debugMode = false
+
private var bundles: [PuzzleCatalog.PuzzleBundle] {
- PuzzleCatalog.bundles()
+ PuzzleCatalog.bundles(includeDebug: debugMode)
}
var body: some View {
@@ -26,18 +28,6 @@ struct BundledBrowseView: View {
}
}
-struct DebugBrowseView: View {
- let onSelected: (String) -> Void
-
- private var puzzles: [PuzzleCatalog.Entry] {
- PuzzleCatalog.debugPuzzles()
- }
-
- var body: some View {
- PuzzleListView(puzzles: puzzles, onSelected: onSelected)
- }
-}
-
/// A list of individual puzzles, each showing a grid preview alongside its
/// title and publisher.
private struct PuzzleListView: View {
diff --git a/Crossmate/Views/Browse/ExternalBrowseView.swift b/Crossmate/Views/Browse/ExternalBrowseView.swift
@@ -0,0 +1,45 @@
+import SwiftUI
+
+struct ExternalBrowseView: View {
+ let onSelected: (String) -> Void
+ let excludedDates: Set<Date>
+
+ @State private var provider: ExternalPuzzleProvider = .newYorkTimes
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Picker("Provider", selection: $provider) {
+ ForEach(ExternalPuzzleProvider.allCases) { provider in
+ Text(provider.title).tag(provider)
+ }
+ }
+ .pickerStyle(.menu)
+ .buttonStyle(.bordered)
+ .fixedSize()
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal)
+ .padding(.top)
+ .padding(.bottom, 8)
+
+ switch provider {
+ case .newYorkTimes:
+ NYTBrowseView(
+ onSelected: onSelected,
+ excludedDates: excludedDates
+ )
+ }
+ }
+ }
+}
+
+private enum ExternalPuzzleProvider: String, CaseIterable, Identifiable {
+ case newYorkTimes
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .newYorkTimes: "New York Times"
+ }
+ }
+}
diff --git a/Crossmate/Views/Browse/NewGameSheet.swift b/Crossmate/Views/Browse/NewGameSheet.swift
@@ -8,9 +8,7 @@ struct NewGameSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(NYTAuthService.self) private var nytAuth
@Environment(EventLog.self) private var eventLog
- @AppStorage("lastPuzzleSource") private var storedSourceRaw = PuzzleSource.bundles.rawValue
- @AppStorage("debugMode") private var debugMode = false
- @State private var selection: PuzzleSource = .bundles
+ @AppStorage("lastPuzzleSource") private var selection: PuzzleSource = .bundles
@State private var duplicateSource: String?
@State private var createError: CreateError?
@@ -22,12 +20,9 @@ struct NewGameSheet: View {
private var availableSources: [PuzzleSource] {
var sources: [PuzzleSource] = [.bundles]
- if debugMode {
- sources.append(.debug)
- }
sources.append(.imported)
if nytAuth.canAttemptNYTFetch {
- sources.append(.nyt)
+ sources.append(.external)
}
return sources
}
@@ -47,12 +42,10 @@ struct NewGameSheet: View {
switch selection {
case .bundles:
BundledBrowseView(onSelected: handleSelected)
- case .debug:
- DebugBrowseView(onSelected: handleSelected)
case .imported:
ImportedBrowseView(onSelected: handleSelected)
- case .nyt:
- NYTBrowseView(
+ case .external:
+ ExternalBrowseView(
onSelected: handleSelected,
excludedDates: store.nytPuzzleDatesInLibrary()
)
@@ -73,13 +66,9 @@ struct NewGameSheet: View {
}
}
.onAppear {
- // Fall back to .bundles if the stored raw value is missing or no
- // longer maps to a case (e.g. the renamed "bundled" -> "bundles").
- let stored = PuzzleSource(rawValue: storedSourceRaw) ?? .bundles
- selection = availableSources.contains(stored) ? stored : .bundles
- }
- .onChange(of: selection) { _, newValue in
- storedSourceRaw = newValue.rawValue
+ if !availableSources.contains(selection) {
+ selection = .bundles
+ }
}
.alert(
"Puzzle Already in Library",
diff --git a/Tests/Unit/AppDefaultsMigratorTests.swift b/Tests/Unit/AppDefaultsMigratorTests.swift
@@ -0,0 +1,34 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("App defaults migrator")
+struct AppDefaultsMigratorTests {
+ @Test("Renames the legacy NYT puzzle source")
+ func renamesLegacyNYTPuzzleSource() throws {
+ let defaults = try makeDefaults()
+ defaults.set("nyt", forKey: "lastPuzzleSource")
+
+ AppDefaultsMigrator.run(defaults: defaults)
+
+ #expect(defaults.string(forKey: "lastPuzzleSource") == PuzzleSource.external.rawValue)
+ }
+
+ @Test("Preserves another puzzle source")
+ func preservesAnotherPuzzleSource() throws {
+ let defaults = try makeDefaults()
+ defaults.set(PuzzleSource.imported.rawValue, forKey: "lastPuzzleSource")
+
+ AppDefaultsMigrator.run(defaults: defaults)
+
+ #expect(defaults.string(forKey: "lastPuzzleSource") == PuzzleSource.imported.rawValue)
+ }
+
+ private func makeDefaults() throws -> UserDefaults {
+ let suiteName = "AppDefaultsMigratorTests.\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ return defaults
+ }
+}
diff --git a/Tests/Unit/PuzzleCatalogTests.swift b/Tests/Unit/PuzzleCatalogTests.swift
@@ -38,14 +38,17 @@ struct PuzzleCatalogTests {
#expect(firstSource?.isEmpty == false)
}
- @Test("The debug bundle is excluded from the bundles")
+ @Test("The debug bundle is excluded by default")
func debugBundleIsExcludedFromBundles() {
#expect(PuzzleCatalog.bundles().allSatisfy { $0.id != "debug" })
}
- @Test("Debug puzzle resources are discoverable")
- func debugPuzzleResourcesAreDiscoverable() {
- let puzzles = PuzzleCatalog.debugPuzzles()
+ @Test("The debug bundle is available when included")
+ func debugPuzzleResourcesAreDiscoverable() throws {
+ let puzzles = try #require(
+ PuzzleCatalog.bundles(includeDebug: true)
+ .first { $0.id == "debug" }
+ ).puzzles
#expect(puzzles.count == 3)
#expect(puzzles.map(\.id) == ["garden", "morning", "sample"])