crossmate

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

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:
MCrossmate.xcodeproj/project.pbxproj | 12++++++++++++
MCrossmate/CrossmateApp.swift | 1+
ACrossmate/Models/AppDefaultsMigrator.swift | 9+++++++++
MCrossmate/Models/PuzzleCatalog.swift | 15+++++----------
MCrossmate/Models/PuzzleSource.swift | 6++----
MCrossmate/Views/Browse/BundledBrowseView.swift | 16+++-------------
ACrossmate/Views/Browse/ExternalBrowseView.swift | 45+++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/Browse/NewGameSheet.swift | 25+++++++------------------
ATests/Unit/AppDefaultsMigratorTests.swift | 34++++++++++++++++++++++++++++++++++
MTests/Unit/PuzzleCatalogTests.swift | 11+++++++----
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"])