commit 0b3e49c1cf46fd94f86f803ef2a9fe5c8fc83c63 parent f1980095c229e708d4f1a5672440138a5b4361b5 Author: Michael Camilleri <[email protected]> Date: Tue, 16 Jun 2026 06:12:57 +0900 Group the Views by feature The Views directory had grown to a single flat folder of 26 files, with no grouping to signal the screen to which a given view belonged. In particular, two of those files had become outliers — 'PuzzleView.swift' at 1753 lines and 'GameListView.swift' at 1009 — large enough that the main view and its many supporting types were awkward to navigate as one unit. This commit sorts the views into feature folders — Puzzle, GameList, Browse, Friends and Settings — alongside a Components folder for the feature-agnostic building blocks ('FlowLayout', 'WeightedVStack', the avatar, thumbnail, banner and slider). The two outliers are then carved along their natural seams. The split is purely structural and is not intended to change behaviour. Co-Authored-By: Claude Opus 4.8 <[email protected]> Diffstat:
37 files changed, 2956 insertions(+), 2866 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -7,46 +7,51 @@ objects = { /* Begin PBXBuildFile section */ + 0063A5FC9F39E37A67F137FF /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED2D830B9EFAD753C233BEB4 /* GameListView.swift */; }; 00A25F5D8DFF62EFA0C4D1D7 /* FriendEntity+DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8D856707B4D76FDBF4AE69 /* FriendEntity+DisplayName.swift */; }; 00F2108848ADC7B4BF3AA0AE /* PlayerSessionNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46801B570FC0B2C791ECDED3 /* PlayerSessionNavigationTests.swift */; }; 014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */; }; 0158184A413AE177F75B4150 /* JournalReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89B1FFD2F90141EA949A8540 /* JournalReplayTests.swift */; }; + 01D1B4C7303F1CA52438FF86 /* HardwareKeyboardInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6940546CFA1E87EF814AA6BB /* HardwareKeyboardInputView.swift */; }; 0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */; }; 025377AF80D45967CE910423 /* SyncMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C190EA5717C291B3F2AE46C /* SyncMonitorTests.swift */; }; 02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */; }; + 036EC1EDDEFD17DCDD9B5F1A /* ClueList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B8B65482CA1739A3863A99E /* ClueList.swift */; }; 04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */; }; - 04FA202932E8B187075CA698 /* FriendsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099C611C9CD5D47D89B62AD0 /* FriendsView.swift */; }; 06AE6DF7AA3274480C591E47 /* EngagementStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B09D52DB46731E92C3E9297C /* EngagementStore.swift */; }; 07A46496EE0B12FD526F36FB /* SessionPushPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C040D5EBC73B1ED47C2C9D4 /* SessionPushPlannerTests.swift */; }; + 082B9BAADE3AFA54EFE30E19 /* PuzzleModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5DF04E70017065DFA95B396 /* PuzzleModifiers.swift */; }; 085B70680087464B8A7BA3EE /* GridSilhouetteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF7062403AC9CFB4FF04BBF3 /* GridSilhouetteTests.swift */; }; 0A7AEB93A473AFCCD9217F49 /* PuzzleSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 362E3A93102B6C9AECD4133A /* PuzzleSessionTests.swift */; }; 0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3292748EAE27B608C769D393 /* PlayerRoster.swift */; }; + 0F2992C16A3A658DEA0F707E /* DiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCACEED6A9235EC6221F4F66 /* DiagnosticsView.swift */; }; 1016604FBD4D63A0B9AAE503 /* CloudQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AAC1E8D2CB3B5117159934 /* CloudQuery.swift */; }; 128915DB37018EE4CC16C856 /* GameCursorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */; }; + 13C0F34520828020AD825D07 /* JoiningPuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18FF14E0D73B0D2DB427F08 /* JoiningPuzzleView.swift */; }; 15768439C1783A1780FBB824 /* SessionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7BD8DFDB41BFBA694A0933 /* SessionCoordinator.swift */; }; 17A754692F05B97DBDD645F2 /* PlayerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0B4F65D017C1FBAC3B23DF /* PlayerSelection.swift */; }; 18D5BB584DBF92A2EC580AEA /* NotificationNavigationBrokerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */; }; 197DDF45C36B9570BB9AE4B5 /* AuthorIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */; }; 1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BDD06460A76D4AF31077732 /* InputMonitor.swift */; }; 1A1A8A9AB36D02E2A5A9ED28 /* GameViewedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9AE0F26E602A9246F5C6ABF /* GameViewedStore.swift */; }; - 1CC2D062086FDC5894BFEFA2 /* DiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */; }; + 1AAFF86B40CBBFF1EC9ADF9F /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B1F07B5DDE2A8B49B28392A /* GridThumbnailView.swift */; }; 1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A253416F4FEA271A80B22A73 /* NYTAuthService.swift */; }; 24F7ED458A1C09F8CF309B35 /* PuzzleNotificationText+GameEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DF9C2FEF0D3584864DFC967 /* PuzzleNotificationText+GameEntity.swift */; }; + 2571BA6482B3E896A80FF393 /* CompactSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B024B2FFB11E51E9724BBE23 /* CompactSlider.swift */; }; 262A9CE8C3CB93869190CFF1 /* GameStoreMergedAuthorCellsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 122BC1863D12DE06388D5DA7 /* GameStoreMergedAuthorCellsTests.swift */; }; 2641299DE1F2E84E8C21E037 /* LogScrubberTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C06E2CC3A77CB306BD2DF867 /* LogScrubberTests.swift */; }; 267ED5B329F05A30430B73A0 /* EngagementHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C701DAE36000DE19F7CC95 /* EngagementHost.swift */; }; 26DC22F88FA10C47BC06975E /* PersistenceRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A467BC00116EEC8500BE6A1 /* PersistenceRecoveryTests.swift */; }; - 2A8FB9C020B2072659C24C8E /* CompactSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE803B2DF05DDF457B27FE2 /* CompactSlider.swift */; }; 2AF2550B08CE79F8615B3076 /* FriendZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */; }; - 2B03A1A36AB55495ED0E8684 /* HardwareKeyboardInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947102E58EFCF898D258AC3E /* HardwareKeyboardInputView.swift */; }; 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */; }; 2C5A15054CCCBF9FD626AFBB /* GameStorePushAddressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */; }; - 2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */; }; + 2DD78CA0CD587AA4E5C4B178 /* PuzzleScoreboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3A251D89028B3CA065DE053 /* PuzzleScoreboard.swift */; }; 309457EC2DFEC476253D54D2 /* PlayerSelectionPublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */; }; 31F2B6A61ED352C7D800149F /* XDAcceptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */; }; 328309D8CC72CCB5623FB2A1 /* EngagementCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67CFF96D54D2DE9C44EB120A /* EngagementCoordinatorTests.swift */; }; 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 */; }; 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 */; }; @@ -54,34 +59,36 @@ 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 */; }; + 44FF4A5334A4086DEA7D8A7B /* GameShareItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 663934F0B7CA8BDD462DFAA4 /* GameShareItem.swift */; }; 47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; }; 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; }; 4A89595E3F6AB50E1D9E6BA8 /* ImportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 462CE0FD356F6137C9BFD30F /* ImportService.swift */; }; 4B8CA45845618D75A3313816 /* GridSilhouette.swift in Sources */ = {isa = PBXBuildFile; fileRef = D16AC7215D0269195FEA8BA8 /* GridSilhouette.swift */; }; - 4C874B69A9D9187490BC2C42 /* FriendAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F91707302CBA406BF7FB8F8A /* FriendAvatarView.swift */; }; 4D7BF839BA71E1BF0AE9BCE9 /* GameStoreContributingDevicesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09EA25E2AE98ACD029EC0129 /* GameStoreContributingDevicesTests.swift */; }; 4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */; }; + 4D9E2C35893E68E47F790994 /* BundledBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7023E506D777DB80B18A7DB5 /* BundledBrowseView.swift */; }; + 4F1A93404828EDBDBBF86716 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C6AB016CA4E2FC69A0E6A4F /* SettingsView.swift */; }; 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; }; 50C02D37A41D55CFA5D307E2 /* NYTPuzzleUpgraderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34489D0864DF76AF436E391 /* NYTPuzzleUpgraderTests.swift */; }; 51E6F7F2FC52C2AA87B9DB45 /* PeerPresenceGraceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08E8592B1CB1336E63498706 /* PeerPresenceGraceTests.swift */; }; - 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; }; 59230713D85AE6895852B06A /* InviteCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10064D171DB7C48D3DE1E769 /* InviteCoordinator.swift */; }; 5992AD4A06D7C6440825E9C6 /* GameArchiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B7539E0AD285C5A3AC3DDA2 /* GameArchiver.swift */; }; + 5E89D1F8FDFE56395997281A /* NewGameSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09C81EFA0B7776CB9713CD63 /* NewGameSheet.swift */; }; 5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */; }; 5EFCD28B3B682DCCF38068D6 /* AnnouncementCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */; }; 5FB26F40F5DB52111E3D1BDC /* CheckResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */; }; 61F8B38587EE49D376B53544 /* ReplayCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */; }; 66A2B6DAB2F132950789CA98 /* LocalMovesSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3E1CB3BA66CA884A4CC985 /* LocalMovesSnapshot.swift */; }; 6850EAE474E589CE1EA2DF68 /* NicknameDirectoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92168360625ECDD36FF50EE8 /* NicknameDirectoryTests.swift */; }; + 689DAEC70934027E76E8116E /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FDE73AD7C543B29C8E493F8 /* KeyboardView.swift */; }; 6A1CA96FF48CBEEE78EA6D34 /* FriendModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B766E872B12DC79ECCD80941 /* FriendModelTests.swift */; }; 6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; }; 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.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 */; }; 6E67C0DCB0416F382EA065B7 /* JournalUploadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */; }; 712A2764596A2D17A0BBBF3B /* FriendZoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800CCFBE90554F287E765755 /* FriendZoneTests.swift */; }; - 740F5EC3331CA9DCCDA682F0 /* SuccessPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B23A692318044351247606DF /* SuccessPanel.swift */; }; - 765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; }; 7714B1C2FBCBBAD9BE8FEAF8 /* GameSummaryThumbnailTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5EEBF169823C172000FC45B /* GameSummaryThumbnailTests.swift */; }; 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; }; 779D1955F350B507A47B1E5B /* ShareLinkShortener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B50A841D92D1F2B173E7DF /* ShareLinkShortener.swift */; }; @@ -91,23 +98,22 @@ 7D9337A19747C79070AB3D59 /* InviteEntity+DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25A040EA4DC9672C895A7AC /* InviteEntity+DisplayName.swift */; }; 7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */; }; 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; }; - 818B1F2693962832BE14578E /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */; }; - 81EFADDD76DC5F24E944C792 /* JoiningPuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 802540DC0A64DE64242ADBE5 /* JoiningPuzzleView.swift */; }; 8225918652DCC822CA1C862F /* PendingEditFlagTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D491B7232333AA8957732387 /* PendingEditFlagTests.swift */; }; 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */; }; - 8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */; }; - 849970A21D62C34EC382A27E /* GameShareItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABB557BA10CBE9909056882 /* GameShareItem.swift */; }; 85A798525FE1DC98210A9E82 /* GameCursorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */; }; 85B9BAC5ED404FE4496250CB /* NYTPuzzleUpgrader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE54EF557E8D808BAA20EA54 /* NYTPuzzleUpgrader.swift */; }; - 886A451A134D88B9324D9A1C /* FriendPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE3412F437AABD2988B6976D /* FriendPickerView.swift */; }; + 884BC090D4E2D416AA52D6FD /* FriendPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E78C275F8A90E3E3EEF190CC /* FriendPickerView.swift */; }; 88A34C8857B2B3D45A6FBCB2 /* PuzzleSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 710BCB6A647A820B106CE666 /* PuzzleSession.swift */; }; 88BACA1689459AC9AED20D43 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; }; 89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */; }; 8AE376C0726116082B15241D /* ShareLinkRouteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5990D989AD745211A18848E4 /* ShareLinkRouteTests.swift */; }; 8B356C953DA0FAF149C3391A /* Puzzles in Resources */ = {isa = PBXBuildFile; fileRef = BA67C509B467132D1B7510A4 /* Puzzles */; }; + 8D8A9F70731C98DD00BE1DA5 /* Layouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836B8D4B351C9225162A82C0 /* Layouts.swift */; }; 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; }; 903681985C17FCB5F97773A9 /* OpenPuzzleBannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA0FC750259EB1D762B0EE /* OpenPuzzleBannerTests.swift */; }; 91703E54DB4679C1911BF994 /* Moves.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86470163BFF956F3DE438506 /* Moves.swift */; }; + 924B29C1EEB29F849A6824C3 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74C8886A66F0877858A67D62 /* AboutView.swift */; }; + 93DB3DD9A8EE994B92E7C084 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED48AD9C3A7A113D101BBD21 /* GridView.swift */; }; 9502840161DB88BB6BB409D5 /* Journal.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF3D29B227D2B0E699423C48 /* Journal.swift */; }; 9582AA583F5EA008FFC82B64 /* ZoneOrphaningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */; }; 9789150602A3321D2E1E7E81 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0BF60C84D92A9024AC1A53FC /* Media.xcassets */; }; @@ -115,56 +121,57 @@ 98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; }; 9AACF424992AE45FD7937064 /* GameStoreCompletionLockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E230B327585E1E3A2921C92 /* GameStoreCompletionLockTests.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 */; }; A0977C7B0B0D928DA569C326 /* NicknameDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3111803C8FFFB0C839217482 /* NicknameDirectory.swift */; }; A133A4B4A0C95AF8708BD7E6 /* PushClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */; }; + A22113A51213068FBF708A56 /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D25D12FF374F83BF4DB83DD /* CellView.swift */; }; A458AF9CA8579AB51B695B08 /* PendingChangeReapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */; }; A65F99414F8CF6704567BB07 /* Archive.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C18E9B47668E008BE4CF86 /* Archive.swift */; }; A78FF09708EDED7ED50BB55B /* PushPayloadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E87E28DC9402A4369647DE50 /* PushPayloadTests.swift */; }; A7FA870D794CA00F7F3F05D2 /* EngagementHostEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400B3C87248F3FCA3F76400B /* EngagementHostEnvironment.swift */; }; + A87E5E615559B6461B1C3F94 /* AnnouncementBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD68E9CA9D3057FE07E985E1 /* AnnouncementBanner.swift */; }; A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */; }; - AA28425BD26F72A9E2B58742 /* BundledBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */; }; AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */; }; AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */; }; - AB05765D2C3F4841026344E5 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AF633D73818BD59F759FAC4 /* AboutView.swift */; }; AB6D98C7A78D91D7BEFB4A4C /* MarketingPuzzleScreenshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AEFE2C7623A899BAABD85F4 /* MarketingPuzzleScreenshotView.swift */; }; - AD50D3E3401C7BF9ED012768 /* AnnouncementBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61687BB3C75A4E1E6ABC82CA /* AnnouncementBanner.swift */; }; AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */; }; AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB851649DE78AAAC5A928C52 /* Square.swift */; }; + B00743DAF8F46F14CE13E909 /* FriendsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 298A9C54A1CC753E860E174E /* FriendsView.swift */; }; B48DE7079BE2F31D2367C5F7 /* SessionPushPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA49DA9E0ADEB11A920787FA /* SessionPushPlanner.swift */; }; B5F78A55C9BCCD24E44D865F /* JournalReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27ECEA51DE42D07495744EF8 /* JournalReplay.swift */; }; B6AB531F4E0C4031B627C539 /* PlayerSelectionPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */; }; B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */; }; - B94919176DEC6EC31637B037 /* ClueList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */; }; + BAB41DBF7D099B1EE46B4ACB /* ClueBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E935CE4384F3B67CC22EEBAC /* ClueBar.swift */; }; BCB9A4D5E06EE5006186465D /* ShareController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C74683332956B0D1CA37589 /* ShareController.swift */; }; + BD317ECC09C9099AC29B8C5D /* FriendAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 065CD67A1D9F7B63AE6B42D6 /* FriendAvatarView.swift */; }; BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */; }; C1930083671621AC79CF95DD /* MovesUpdaterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AF6157D97271205626E207C /* MovesUpdaterTests.swift */; }; C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */; }; C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; }; C511387D9FFBCC2E2F5EF699 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 51318FC5DAE02D35CB005729 /* NotificationService.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C58F15CBEADA72032B54009D /* ReplayControlsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847415468DBB1C566D18BC17 /* ReplayControlsTests.swift */; }; - C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */; }; + C843CADAA263CED503528A4E /* NYTBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3413F8755236FC0578AF8109 /* NYTBrowseView.swift */; }; C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */; }; C8ACF431021E7BEE61A99153 /* FriendController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E655698481325C92EF5C348B /* FriendController.swift */; }; - C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */; }; C9864C9940C9DAAD0A788094 /* ReplayLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFD574AC2D0A910053E2A73 /* ReplayLoader.swift */; }; CABF8BFAA30B9F26C482FAB9 /* EngagementCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FDE03B4A77A8095ED2C23AB /* EngagementCoordinator.swift */; }; CC250D6BA9B41CB722D8A62E /* CloudService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BC76178319D0D669CD50FF /* CloudService.swift */; }; CCF3867C32C3F36E4F69A59E /* DebuggingMonitors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */; }; CEDF853009D0C367035F1F76 /* PlayerNamePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */; }; CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */; }; + CF1DC343A5D3110EDFA703AB /* LastUpdatedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF3B7E191D571FD800A4D719 /* LastUpdatedView.swift */; }; CF56BBB90855367CB85FEB43 /* PUZToXDConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */; }; - CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */; }; D13ECFAE05DB508577D2FF66 /* RecordBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5267DDA1A330DCBD07303D44 /* RecordBuilder.swift */; }; - D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */; }; D240BF6498A9148855DB7734 /* EngagementLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB0580C9B7C778F34BE6AC2 /* EngagementLifecycle.swift */; }; + D2AC1D9BD7E387B06B9B8A0E /* PuzzleHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADBA3FB1334DB816E62B7D9B /* PuzzleHeader.swift */; }; D4EDC0D426688B295DA77C08 /* ShareLinkRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2ED0D601BB574618C15B5EF /* ShareLinkRoute.swift */; }; - D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */; }; + D5022BFB2F8F2E5904EDF5C8 /* GameCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F34401948BC53DA9C93D64B /* GameCardView.swift */; }; D519F54F8CE0BD53D9C6144C /* AppServicesAnnouncementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9998739ED0875A17271B7899 /* AppServicesAnnouncementTests.swift */; }; D58980B92C99122C368D4216 /* GameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EE5BA78566EDED68D846AB /* GameStore.swift */; }; D94FF5DFB9412D2DC24F6574 /* RecordApplier.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD63A9B20168F3B81AF4348F /* RecordApplier.swift */; }; DB098F40C6950E29B4BF10A7 /* ArchiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EA9CF96312BFF5340CE2A7 /* ArchiveTests.swift */; }; - DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */; }; + DDC7994B951A3A7B836B36F6 /* SuccessPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A62DA6F7138876CA5A27EF /* SuccessPanel.swift */; }; DE90CC8BE23A0EFC4A32FFA5 /* MovesInboundTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */; }; DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */; }; E15A40AA623B60279E8DDF43 /* CloudDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */; }; @@ -183,10 +190,11 @@ F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */; }; F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */; }; F5F333B36654AEAF69A3C220 /* MovesJournalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C92190C4A344EC319A0F88 /* MovesJournalTests.swift */; }; - F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */; }; + F627D68B521FEA85EB80A850 /* CalendarDayCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF20BDF7FF6423BA4FD911D5 /* CalendarDayCell.swift */; }; F8A4B3A1F9601654C60550B3 /* PushPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2C9D3E7FCE2D42C5B7E3856 /* PushPayload.swift */; }; + F8D37DBE75D7B3F039A8FAC8 /* ImportedBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A832061C19BA0F073617CA2 /* ImportedBrowseView.swift */; }; F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */; }; - FFBE2EC8A3A60E119A0D314F /* NYTBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */; }; + FC480FE2930EAE406F5BBBDA /* GameRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD2570A5A3573D66B3C4A52 /* GameRowView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -223,16 +231,15 @@ /* Begin PBXFileReference section */ 03D59EA74A4BA084AD97478D /* AccountPushCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPushCoordinator.swift; sourceTree = "<group>"; }; 057F2B8B8A894D08BB801219 /* ShareLinkShortenerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLinkShortenerTests.swift; sourceTree = "<group>"; }; - 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTLoginView.swift; sourceTree = "<group>"; }; + 065CD67A1D9F7B63AE6B42D6 /* FriendAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendAvatarView.swift; sourceTree = "<group>"; }; 07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDiagnostics.swift; sourceTree = "<group>"; }; 08E8592B1CB1336E63498706 /* PeerPresenceGraceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerPresenceGraceTests.swift; sourceTree = "<group>"; }; - 099C611C9CD5D47D89B62AD0 /* FriendsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsView.swift; sourceTree = "<group>"; }; + 09C81EFA0B7776CB9713CD63 /* NewGameSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGameSheet.swift; sourceTree = "<group>"; }; 09EA25E2AE98ACD029EC0129 /* GameStoreContributingDevicesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreContributingDevicesTests.swift; sourceTree = "<group>"; }; 0BDC16DA7F762F4C5F4BED14 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; }; 0BF60C84D92A9024AC1A53FC /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; }; 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; }; 0C190EA5717C291B3F2AE46C /* SyncMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncMonitorTests.swift; sourceTree = "<group>"; }; - 0CE803B2DF05DDF457B27FE2 /* CompactSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactSlider.swift; sourceTree = "<group>"; }; 0DF9C2FEF0D3584864DFC967 /* PuzzleNotificationText+GameEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PuzzleNotificationText+GameEntity.swift"; sourceTree = "<group>"; }; 0E230B327585E1E3A2921C92 /* GameStoreCompletionLockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreCompletionLockTests.swift; sourceTree = "<group>"; }; 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionMonitor.swift; sourceTree = "<group>"; }; @@ -244,14 +251,16 @@ 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossmateApp.swift; sourceTree = "<group>"; }; 16AAC1E8D2CB3B5117159934 /* CloudQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudQuery.swift; sourceTree = "<group>"; }; 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebuggingMonitors.swift; sourceTree = "<group>"; }; + 1747D4DCB4BCC831069BBE07 /* NYTLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTLoginView.swift; sourceTree = "<group>"; }; 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRosterTests.swift; sourceTree = "<group>"; }; 18C701DAE36000DE19F7CC95 /* EngagementHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementHost.swift; sourceTree = "<group>"; }; 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>"; }; - 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; }; 20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; }; 27ECEA51DE42D07495744EF8 /* JournalReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalReplay.swift; sourceTree = "<group>"; }; 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGameZoneTests.swift; sourceTree = "<group>"; }; + 298A9C54A1CC753E860E174E /* FriendsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsView.swift; sourceTree = "<group>"; }; + 2A832061C19BA0F073617CA2 /* ImportedBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportedBrowseView.swift; sourceTree = "<group>"; }; 2D2FD896D75863554E31654C /* NotificationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationState.swift; sourceTree = "<group>"; }; 2D5B1E8E12B86DF6CA478F65 /* BadgeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeCoordinator.swift; sourceTree = "<group>"; }; 2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalUploadTests.swift; sourceTree = "<group>"; }; @@ -259,11 +268,13 @@ 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreUnreadMovesTests.swift; sourceTree = "<group>"; }; 3292748EAE27B608C769D393 /* PlayerRoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRoster.swift; sourceTree = "<group>"; }; 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; }; + 3413F8755236FC0578AF8109 /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; }; 362E3A93102B6C9AECD4133A /* PuzzleSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSessionTests.swift; sourceTree = "<group>"; }; - 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = "<group>"; }; + 3EF436B410916399336AC106 /* RecordEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordEditorView.swift; sourceTree = "<group>"; }; + 3FDE73AD7C543B29C8E493F8 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = "<group>"; }; 3FFD574AC2D0A910053E2A73 /* ReplayLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayLoader.swift; sourceTree = "<group>"; }; 400B3C87248F3FCA3F76400B /* EngagementHostEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementHostEnvironment.swift; sourceTree = "<group>"; }; - 434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsView.swift; sourceTree = "<group>"; }; + 41A62DA6F7138876CA5A27EF /* SuccessPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuccessPanel.swift; sourceTree = "<group>"; }; 43DC132D49361C56DE79C13E /* GameMutator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutator.swift; sourceTree = "<group>"; }; 443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializerMovesTests.swift; sourceTree = "<group>"; }; 44F86F0F1883A93F9622FB67 /* CloudZones.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudZones.swift; sourceTree = "<group>"; }; @@ -274,7 +285,6 @@ 46801B570FC0B2C791ECDED3 /* PlayerSessionNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSessionNavigationTests.swift; sourceTree = "<group>"; }; 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationStateTests.swift; sourceTree = "<group>"; }; 4A467BC00116EEC8500BE6A1 /* PersistenceRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceRecoveryTests.swift; sourceTree = "<group>"; }; - 4AF633D73818BD59F759FAC4 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; }; 4B33C21324E1474BCC126AA0 /* MovesCodecLegacyDecodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesCodecLegacyDecodeTests.swift; sourceTree = "<group>"; }; 4DB0580C9B7C778F34BE6AC2 /* EngagementLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementLifecycle.swift; sourceTree = "<group>"; }; 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalog.swift; sourceTree = "<group>"; }; @@ -285,40 +295,44 @@ 5267DDA1A330DCBD07303D44 /* RecordBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordBuilder.swift; sourceTree = "<group>"; }; 52B50A841D92D1F2B173E7DF /* ShareLinkShortener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLinkShortener.swift; sourceTree = "<group>"; }; 56BC76178319D0D669CD50FF /* CloudService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudService.swift; sourceTree = "<group>"; }; + 57B1734CF731C2E405A39159 /* PuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleView.swift; sourceTree = "<group>"; }; 5990D989AD745211A18848E4 /* ShareLinkRouteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLinkRouteTests.swift; sourceTree = "<group>"; }; - 5ABB557BA10CBE9909056882 /* GameShareItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameShareItem.swift; sourceTree = "<group>"; }; 5C74683332956B0D1CA37589 /* ShareController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareController.swift; sourceTree = "<group>"; }; 5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRecordPresenceTests.swift; sourceTree = "<group>"; }; 5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisherTests.swift; sourceTree = "<group>"; }; 603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayCacheTests.swift; sourceTree = "<group>"; }; 605CA0FC7AF069CE3A3B38C1 /* RecentChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentChanges.swift; sourceTree = "<group>"; }; 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCursorStoreTests.swift; sourceTree = "<group>"; }; - 61687BB3C75A4E1E6ABC82CA /* AnnouncementBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementBanner.swift; sourceTree = "<group>"; }; 64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; }; + 663934F0B7CA8BDD462DFAA4 /* GameShareItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameShareItem.swift; sourceTree = "<group>"; }; 67CFF96D54D2DE9C44EB120A /* EngagementCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementCoordinatorTests.swift; sourceTree = "<group>"; }; 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareRoutingTests.swift; sourceTree = "<group>"; }; + 6940546CFA1E87EF814AA6BB /* HardwareKeyboardInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareKeyboardInputView.swift; sourceTree = "<group>"; }; + 6B1F07B5DDE2A8B49B28392A /* GridThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridThumbnailView.swift; sourceTree = "<group>"; }; 6BDD06460A76D4AF31077732 /* InputMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMonitor.swift; sourceTree = "<group>"; }; 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridStateMergerTests.swift; sourceTree = "<group>"; }; 6F0B4F65D017C1FBAC3B23DF /* PlayerSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelection.swift; sourceTree = "<group>"; }; + 6F34401948BC53DA9C93D64B /* GameCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCardView.swift; sourceTree = "<group>"; }; + 7023E506D777DB80B18A7DB5 /* BundledBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledBrowseView.swift; sourceTree = "<group>"; }; 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DriveMonitor.swift; sourceTree = "<group>"; }; 710BCB6A647A820B106CE666 /* PuzzleSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSession.swift; sourceTree = "<group>"; }; 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisher.swift; sourceTree = "<group>"; }; 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; }; - 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; + 74C8886A66F0877858A67D62 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; }; 78C92190C4A344EC319A0F88 /* MovesJournalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesJournalTests.swift; sourceTree = "<group>"; }; 7A3E1CB3BA66CA884A4CC985 /* LocalMovesSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMovesSnapshot.swift; sourceTree = "<group>"; }; 7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendZone.swift; sourceTree = "<group>"; }; 7A7BD8DFDB41BFBA694A0933 /* SessionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCoordinator.swift; sourceTree = "<group>"; }; 7B3E1A382B24A7803701D947 /* Crossmate.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Crossmate.entitlements; sourceTree = "<group>"; }; - 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = "<group>"; }; + 7B8B65482CA1739A3863A99E /* ClueList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClueList.swift; sourceTree = "<group>"; }; + 7C6AB016CA4E2FC69A0E6A4F /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesUpdater.swift; sourceTree = "<group>"; }; 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializerTests.swift; sourceTree = "<group>"; }; 800CCFBE90554F287E765755 /* FriendZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendZoneTests.swift; sourceTree = "<group>"; }; - 802540DC0A64DE64242ADBE5 /* JoiningPuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoiningPuzzleView.swift; sourceTree = "<group>"; }; 80B377D237AC14B9856579E1 /* RecentChangesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentChangesTests.swift; sourceTree = "<group>"; }; + 836B8D4B351C9225162A82C0 /* Layouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Layouts.swift; sourceTree = "<group>"; }; 847415468DBB1C566D18BC17 /* ReplayControlsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayControlsTests.swift; sourceTree = "<group>"; }; 86470163BFF956F3DE438506 /* Moves.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Moves.swift; sourceTree = "<group>"; }; - 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportedBrowseView.swift; sourceTree = "<group>"; }; 88E8AACB638FE5724B534B41 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 89B1FFD2F90141EA949A8540 /* JournalReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalReplayTests.swift; sourceTree = "<group>"; }; 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushClient.swift; sourceTree = "<group>"; }; @@ -330,26 +344,27 @@ 927186458ED03FD0C5660765 /* CrossmateModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CrossmateModel.xcdatamodel; sourceTree = "<group>"; }; 93EE5BA78566EDED68D846AB /* GameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStore.swift; sourceTree = "<group>"; }; 9447F0FE34C63810C6F1D8BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; - 947102E58EFCF898D258AC3E /* HardwareKeyboardInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareKeyboardInputView.swift; sourceTree = "<group>"; }; 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnsureGameEntityTests.swift; sourceTree = "<group>"; }; 978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementCenterTests.swift; sourceTree = "<group>"; }; 9998739ED0875A17271B7899 /* AppServicesAnnouncementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServicesAnnouncementTests.swift; sourceTree = "<group>"; }; 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncState+Helpers.swift"; sourceTree = "<group>"; }; - 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledBrowseView.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>"; }; + 9D25D12FF374F83BF4DB83DD /* CellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellView.swift; sourceTree = "<group>"; }; 9F8D856707B4D76FDBF4AE69 /* FriendEntity+DisplayName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FriendEntity+DisplayName.swift"; sourceTree = "<group>"; }; A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.swift; sourceTree = "<group>"; }; + A3A251D89028B3CA065DE053 /* PuzzleScoreboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleScoreboard.swift; sourceTree = "<group>"; }; 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>"; }; ACC295195602B3DDF7BB3895 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; }; - AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleView.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>"; }; + 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>"; }; B09D52DB46731E92C3E9297C /* EngagementStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementStore.swift; sourceTree = "<group>"; }; B135C285570F91181595B405 /* CellMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellMark.swift; sourceTree = "<group>"; }; B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorIdentity.swift; sourceTree = "<group>"; }; - B23A692318044351247606DF /* SuccessPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuccessPanel.swift; sourceTree = "<group>"; }; B34489D0864DF76AF436E391 /* NYTPuzzleUpgraderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleUpgraderTests.swift; sourceTree = "<group>"; }; B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PUZToXDConverter.swift; sourceTree = "<group>"; }; B3D873ABDF871E14794A2845 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = "<group>"; }; @@ -361,44 +376,45 @@ B9EA9CF96312BFF5340CE2A7 /* ArchiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveTests.swift; sourceTree = "<group>"; }; BA67C509B467132D1B7510A4 /* Puzzles */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Puzzles; sourceTree = SOURCE_ROOT; }; BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChangeReapTests.swift; sourceTree = "<group>"; }; + BCACEED6A9235EC6221F4F66 /* DiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsView.swift; sourceTree = "<group>"; }; BD63A9B20168F3B81AF4348F /* RecordApplier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordApplier.swift; sourceTree = "<group>"; }; BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverter.swift; sourceTree = "<group>"; }; BF7062403AC9CFB4FF04BBF3 /* GridSilhouetteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridSilhouetteTests.swift; sourceTree = "<group>"; }; BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutatorTests.swift; sourceTree = "<group>"; }; C06E2CC3A77CB306BD2DF867 /* LogScrubberTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogScrubberTests.swift; sourceTree = "<group>"; }; - C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayCell.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>"; }; 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>"; }; - CAB4BB9E160C3A59C653E7A9 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; }; CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = "<group>"; }; + CD68E9CA9D3057FE07E985E1 /* AnnouncementBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementBanner.swift; sourceTree = "<group>"; }; CE54EF557E8D808BAA20EA54 /* NYTPuzzleUpgrader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleUpgrader.swift; sourceTree = "<group>"; }; CE7CEB8980A9664BAAA5D196 /* PuzzleNotificationText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationText.swift; sourceTree = "<group>"; }; + CF20BDF7FF6423BA4FD911D5 /* CalendarDayCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayCell.swift; sourceTree = "<group>"; }; CF3D29B227D2B0E699423C48 /* Journal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Journal.swift; sourceTree = "<group>"; }; CFC4FF046BF772646B5CA73F /* Presence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presence.swift; sourceTree = "<group>"; }; D16AC7215D0269195FEA8BA8 /* GridSilhouette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridSilhouette.swift; sourceTree = "<group>"; }; D491B7232333AA8957732387 /* PendingEditFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingEditFlagTests.swift; sourceTree = "<group>"; }; D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridThumbnailView.swift; sourceTree = "<group>"; }; DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; }; DB851649DE78AAAC5A928C52 /* Square.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Square.swift; sourceTree = "<group>"; }; + DBD2570A5A3573D66B3C4A52 /* GameRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameRowView.swift; sourceTree = "<group>"; }; + E18FF14E0D73B0D2DB427F08 /* JoiningPuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoiningPuzzleView.swift; sourceTree = "<group>"; }; E25A040EA4DC9672C895A7AC /* InviteEntity+DisplayName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InviteEntity+DisplayName.swift"; sourceTree = "<group>"; }; E2ED0D601BB574618C15B5EF /* ShareLinkRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLinkRoute.swift; sourceTree = "<group>"; }; - E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordEditorView.swift; sourceTree = "<group>"; }; E5EEBF169823C172000FC45B /* GameSummaryThumbnailTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameSummaryThumbnailTests.swift; sourceTree = "<group>"; }; E655698481325C92EF5C348B /* FriendController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendController.swift; sourceTree = "<group>"; }; + E78C275F8A90E3E3EEF190CC /* FriendPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendPickerView.swift; sourceTree = "<group>"; }; E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSource.swift; sourceTree = "<group>"; }; E87E28DC9402A4369647DE50 /* PushPayloadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPayloadTests.swift; sourceTree = "<group>"; }; - E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClueList.swift; sourceTree = "<group>"; }; + E935CE4384F3B67CC22EEBAC /* ClueBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClueBar.swift; sourceTree = "<group>"; }; EAC61E2582D94B1E6EC67136 /* XDFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDFileType.swift; sourceTree = "<group>"; }; + ED2D830B9EFAD753C233BEB4 /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = "<group>"; }; + ED48AD9C3A7A113D101BBD21 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; }; ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthServiceTests.swift; sourceTree = "<group>"; }; - EE3412F437AABD2988B6976D /* FriendPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendPickerView.swift; sourceTree = "<group>"; }; EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesInboundTests.swift; sourceTree = "<group>"; }; - F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGameSheet.swift; sourceTree = "<group>"; }; + F5DF04E70017065DFA95B396 /* PuzzleModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleModifiers.swift; sourceTree = "<group>"; }; F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionMonitorTests.swift; sourceTree = "<group>"; }; - F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellView.swift; sourceTree = "<group>"; }; - F91707302CBA406BF7FB8F8A /* FriendAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendAvatarView.swift; sourceTree = "<group>"; }; F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; }; FAF6E3F3558E128E7A482A61 /* PushRequestAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushRequestAuthenticator.swift; sourceTree = "<group>"; }; FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PUZToXDConverterTests.swift; sourceTree = "<group>"; }; @@ -445,6 +461,18 @@ path = Sync; sourceTree = "<group>"; }; + 08204641416EEBE67C3EF5F6 /* GameList */ = { + isa = PBXGroup; + children = ( + 6F34401948BC53DA9C93D64B /* GameCardView.swift */, + ED2D830B9EFAD753C233BEB4 /* GameListView.swift */, + DBD2570A5A3573D66B3C4A52 /* GameRowView.swift */, + 663934F0B7CA8BDD462DFAA4 /* GameShareItem.swift */, + AF3B7E191D571FD800A4D719 /* LastUpdatedView.swift */, + ); + path = GameList; + sourceTree = "<group>"; + }; 12BCF7948BC2C200C647C279 /* Products */ = { isa = PBXGroup; children = ( @@ -455,6 +483,17 @@ name = Products; sourceTree = "<group>"; }; + 1FB709FE7325CD51300A22F0 /* Settings */ = { + isa = PBXGroup; + children = ( + 74C8886A66F0877858A67D62 /* AboutView.swift */, + BCACEED6A9235EC6221F4F66 /* DiagnosticsView.swift */, + 3EF436B410916399336AC106 /* RecordEditorView.swift */, + 7C6AB016CA4E2FC69A0E6A4F /* SettingsView.swift */, + ); + path = Settings; + sourceTree = "<group>"; + }; 212DB6FCF46C41F81C41D232 /* Unit */ = { isa = PBXGroup; children = ( @@ -539,6 +578,15 @@ path = Models; sourceTree = "<group>"; }; + 486B6F0E3B86BC2765CCEC33 /* Friends */ = { + isa = PBXGroup; + children = ( + E78C275F8A90E3E3EEF190CC /* FriendPickerView.swift */, + 298A9C54A1CC753E860E174E /* FriendsView.swift */, + ); + path = Friends; + sourceTree = "<group>"; + }; 565DBAFC8DB2589B3F0AF90E /* Persistence */ = { isa = PBXGroup; children = ( @@ -579,36 +627,47 @@ 84445EA9CACB6AAAEDE6965F /* Views */ = { isa = PBXGroup; children = ( - 4AF633D73818BD59F759FAC4 /* AboutView.swift */, - 61687BB3C75A4E1E6ABC82CA /* AnnouncementBanner.swift */, - 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */, - C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */, - F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */, - E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */, - 0CE803B2DF05DDF457B27FE2 /* CompactSlider.swift */, - 434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */, - F91707302CBA406BF7FB8F8A /* FriendAvatarView.swift */, - EE3412F437AABD2988B6976D /* FriendPickerView.swift */, - 099C611C9CD5D47D89B62AD0 /* FriendsView.swift */, - 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */, - 5ABB557BA10CBE9909056882 /* GameShareItem.swift */, - D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */, - CAB4BB9E160C3A59C653E7A9 /* GridView.swift */, - 947102E58EFCF898D258AC3E /* HardwareKeyboardInputView.swift */, - 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */, - 802540DC0A64DE64242ADBE5 /* JoiningPuzzleView.swift */, - 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */, - F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */, - 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */, - 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */, - AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */, - E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */, - 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */, - B23A692318044351247606DF /* SuccessPanel.swift */, + AB1863007AA59F4769675599 /* Browse */, + 99CEFF0AB23C2B9329A9F8B2 /* Components */, + 486B6F0E3B86BC2765CCEC33 /* Friends */, + 08204641416EEBE67C3EF5F6 /* GameList */, + 895088B5D0214046158C6D24 /* Puzzle */, + 1FB709FE7325CD51300A22F0 /* Settings */, ); path = Views; sourceTree = "<group>"; }; + 895088B5D0214046158C6D24 /* Puzzle */ = { + isa = PBXGroup; + children = ( + 9D25D12FF374F83BF4DB83DD /* CellView.swift */, + E935CE4384F3B67CC22EEBAC /* ClueBar.swift */, + 7B8B65482CA1739A3863A99E /* ClueList.swift */, + ED48AD9C3A7A113D101BBD21 /* GridView.swift */, + 6940546CFA1E87EF814AA6BB /* HardwareKeyboardInputView.swift */, + E18FF14E0D73B0D2DB427F08 /* JoiningPuzzleView.swift */, + 3FDE73AD7C543B29C8E493F8 /* KeyboardView.swift */, + ADBA3FB1334DB816E62B7D9B /* PuzzleHeader.swift */, + F5DF04E70017065DFA95B396 /* PuzzleModifiers.swift */, + A3A251D89028B3CA065DE053 /* PuzzleScoreboard.swift */, + 57B1734CF731C2E405A39159 /* PuzzleView.swift */, + 41A62DA6F7138876CA5A27EF /* SuccessPanel.swift */, + ); + path = Puzzle; + sourceTree = "<group>"; + }; + 99CEFF0AB23C2B9329A9F8B2 /* Components */ = { + isa = PBXGroup; + children = ( + CD68E9CA9D3057FE07E985E1 /* AnnouncementBanner.swift */, + B024B2FFB11E51E9724BBE23 /* CompactSlider.swift */, + 065CD67A1D9F7B63AE6B42D6 /* FriendAvatarView.swift */, + 6B1F07B5DDE2A8B49B28392A /* GridThumbnailView.swift */, + 836B8D4B351C9225162A82C0 /* Layouts.swift */, + ); + path = Components; + sourceTree = "<group>"; + }; 9BF7383FE2AB07F12434C013 /* Shared */ = { isa = PBXGroup; children = ( @@ -620,6 +679,19 @@ path = Shared; sourceTree = "<group>"; }; + AB1863007AA59F4769675599 /* Browse */ = { + isa = PBXGroup; + children = ( + 7023E506D777DB80B18A7DB5 /* BundledBrowseView.swift */, + CF20BDF7FF6423BA4FD911D5 /* CalendarDayCell.swift */, + 2A832061C19BA0F073617CA2 /* ImportedBrowseView.swift */, + 09C81EFA0B7776CB9713CD63 /* NewGameSheet.swift */, + 3413F8755236FC0578AF8109 /* NYTBrowseView.swift */, + 1747D4DCB4BCC831069BBE07 /* NYTLoginView.swift */, + ); + path = Browse; + sourceTree = "<group>"; + }; ABB371EF2574E95782CB05FD /* Sync */ = { isa = PBXGroup; children = ( @@ -901,75 +973,80 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - AB05765D2C3F4841026344E5 /* AboutView.swift in Sources */, + 924B29C1EEB29F849A6824C3 /* AboutView.swift in Sources */, EA0AA522F6C383034C4572F4 /* AccountPushCoordinator.swift in Sources */, - AD50D3E3401C7BF9ED012768 /* AnnouncementBanner.swift in Sources */, + A87E5E615559B6461B1C3F94 /* AnnouncementBanner.swift in Sources */, 5EFCD28B3B682DCCF38068D6 /* AnnouncementCenter.swift in Sources */, 78802AFDF6273231781CC0DC /* AppServices.swift in Sources */, A65F99414F8CF6704567BB07 /* Archive.swift in Sources */, 197DDF45C36B9570BB9AE4B5 /* AuthorIdentity.swift in Sources */, EB6E99226D5EE27668787008 /* BadgeCoordinator.swift in Sources */, - AA28425BD26F72A9E2B58742 /* BundledBrowseView.swift in Sources */, - C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */, + 4D9E2C35893E68E47F790994 /* BundledBrowseView.swift in Sources */, + F627D68B521FEA85EB80A850 /* CalendarDayCell.swift in Sources */, 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */, - CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */, + A22113A51213068FBF708A56 /* CellView.swift in Sources */, 5FB26F40F5DB52111E3D1BDC /* CheckResult.swift in Sources */, E15A40AA623B60279E8DDF43 /* CloudDiagnostics.swift in Sources */, 1016604FBD4D63A0B9AAE503 /* CloudQuery.swift in Sources */, CC250D6BA9B41CB722D8A62E /* CloudService.swift in Sources */, E16A8FE849A8E8BCC0F32280 /* CloudZones.swift in Sources */, - B94919176DEC6EC31637B037 /* ClueList.swift in Sources */, - 2A8FB9C020B2072659C24C8E /* CompactSlider.swift in Sources */, + BAB41DBF7D099B1EE46B4ACB /* ClueBar.swift in Sources */, + 036EC1EDDEFD17DCDD9B5F1A /* ClueList.swift in Sources */, + 2571BA6482B3E896A80FF393 /* CompactSlider.swift in Sources */, DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */, C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */, CCF3867C32C3F36E4F69A59E /* DebuggingMonitors.swift in Sources */, - 1CC2D062086FDC5894BFEFA2 /* DiagnosticsView.swift in Sources */, + 0F2992C16A3A658DEA0F707E /* DiagnosticsView.swift in Sources */, 978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */, CABF8BFAA30B9F26C482FAB9 /* EngagementCoordinator.swift in Sources */, 267ED5B329F05A30430B73A0 /* EngagementHost.swift in Sources */, A7FA870D794CA00F7F3F05D2 /* EngagementHostEnvironment.swift in Sources */, D240BF6498A9148855DB7734 /* EngagementLifecycle.swift in Sources */, 06AE6DF7AA3274480C591E47 /* EngagementStore.swift in Sources */, - 4C874B69A9D9187490BC2C42 /* FriendAvatarView.swift in Sources */, + BD317ECC09C9099AC29B8C5D /* FriendAvatarView.swift in Sources */, C8ACF431021E7BEE61A99153 /* FriendController.swift in Sources */, 00A25F5D8DFF62EFA0C4D1D7 /* FriendEntity+DisplayName.swift in Sources */, - 886A451A134D88B9324D9A1C /* FriendPickerView.swift in Sources */, + 884BC090D4E2D416AA52D6FD /* FriendPickerView.swift in Sources */, 2AF2550B08CE79F8615B3076 /* FriendZone.swift in Sources */, - 04FA202932E8B187075CA698 /* FriendsView.swift in Sources */, + B00743DAF8F46F14CE13E909 /* FriendsView.swift in Sources */, 98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */, 5992AD4A06D7C6440825E9C6 /* GameArchiver.swift in Sources */, + D5022BFB2F8F2E5904EDF5C8 /* GameCardView.swift in Sources */, 128915DB37018EE4CC16C856 /* GameCursorStore.swift in Sources */, - 818B1F2693962832BE14578E /* GameListView.swift in Sources */, + 0063A5FC9F39E37A67F137FF /* GameListView.swift in Sources */, 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */, - 849970A21D62C34EC382A27E /* GameShareItem.swift in Sources */, + FC480FE2930EAE406F5BBBDA /* GameRowView.swift in Sources */, + 44FF4A5334A4086DEA7D8A7B /* GameShareItem.swift in Sources */, D58980B92C99122C368D4216 /* GameStore.swift in Sources */, 1A1A8A9AB36D02E2A5A9ED28 /* GameViewedStore.swift in Sources */, 4B8CA45845618D75A3313816 /* GridSilhouette.swift in Sources */, ED6C21CD9F5AB286B69A02E4 /* GridStateMerger.swift in Sources */, - C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */, - 765B50552B13175F91A25EA1 /* GridView.swift in Sources */, - 2B03A1A36AB55495ED0E8684 /* HardwareKeyboardInputView.swift in Sources */, + 1AAFF86B40CBBFF1EC9ADF9F /* GridThumbnailView.swift in Sources */, + 93DB3DD9A8EE994B92E7C084 /* GridView.swift in Sources */, + 01D1B4C7303F1CA52438FF86 /* HardwareKeyboardInputView.swift in Sources */, 4A89595E3F6AB50E1D9E6BA8 /* ImportService.swift in Sources */, - 8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */, + F8D37DBE75D7B3F039A8FAC8 /* ImportedBrowseView.swift in Sources */, 1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */, 59230713D85AE6895852B06A /* InviteCoordinator.swift in Sources */, 7D9337A19747C79070AB3D59 /* InviteEntity+DisplayName.swift in Sources */, - 81EFADDD76DC5F24E944C792 /* JoiningPuzzleView.swift in Sources */, + 13C0F34520828020AD825D07 /* JoiningPuzzleView.swift in Sources */, 9502840161DB88BB6BB409D5 /* Journal.swift in Sources */, B5F78A55C9BCCD24E44D865F /* JournalReplay.swift in Sources */, - F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */, + 689DAEC70934027E76E8116E /* KeyboardView.swift in Sources */, 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */, + CF1DC343A5D3110EDFA703AB /* LastUpdatedView.swift in Sources */, + 8D8A9F70731C98DD00BE1DA5 /* Layouts.swift in Sources */, 66A2B6DAB2F132950789CA98 /* LocalMovesSnapshot.swift in Sources */, AB6D98C7A78D91D7BEFB4A4C /* MarketingPuzzleScreenshotView.swift in Sources */, 91703E54DB4679C1911BF994 /* Moves.swift in Sources */, 4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */, 1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */, - FFBE2EC8A3A60E119A0D314F /* NYTBrowseView.swift in Sources */, - D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */, + C843CADAA263CED503528A4E /* NYTBrowseView.swift in Sources */, + 6D2AF361587E43D807BA212F /* NYTLoginView.swift in Sources */, 0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */, 85B9BAC5ED404FE4496250CB /* NYTPuzzleUpgrader.swift in Sources */, B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */, - DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */, + 5E89D1F8FDFE56395997281A /* NewGameSheet.swift in Sources */, 36E2AAF1EE1314E13477EE85 /* NicknameDirectory.swift in Sources */, 6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */, CF56BBB90855367CB85FEB43 /* PUZToXDConverter.swift in Sources */, @@ -987,27 +1064,30 @@ F2F7CB23DA62BF714632B097 /* PushRequestAuthenticator.swift in Sources */, 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */, 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */, + D2AC1D9BD7E387B06B9B8A0E /* PuzzleHeader.swift in Sources */, + 082B9BAADE3AFA54EFE30E19 /* PuzzleModifiers.swift in Sources */, 24F7ED458A1C09F8CF309B35 /* PuzzleNotificationText+GameEntity.swift in Sources */, E81F92AAB2968997C3D68809 /* PuzzleNotificationText.swift in Sources */, + 2DD78CA0CD587AA4E5C4B178 /* PuzzleScoreboard.swift in Sources */, 88A34C8857B2B3D45A6FBCB2 /* PuzzleSession.swift in Sources */, E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */, - 2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */, + 9C52C48DB4996D5C83DEC144 /* PuzzleView.swift in Sources */, 9AD8936D94FD676B23DFBB77 /* RecentChanges.swift in Sources */, D94FF5DFB9412D2DC24F6574 /* RecordApplier.swift in Sources */, D13ECFAE05DB508577D2FF66 /* RecordBuilder.swift in Sources */, - D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */, + 35777D908A7D062730A18EF9 /* RecordEditorView.swift in Sources */, CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */, E1FBC33E3348547D4DF946C4 /* ReplayControls.swift in Sources */, C9864C9940C9DAAD0A788094 /* ReplayLoader.swift in Sources */, 15768439C1783A1780FBB824 /* SessionCoordinator.swift in Sources */, 5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */, B48DE7079BE2F31D2367C5F7 /* SessionPushPlanner.swift in Sources */, - 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */, + 4F1A93404828EDBDBBF86716 /* SettingsView.swift in Sources */, BCB9A4D5E06EE5006186465D /* ShareController.swift in Sources */, D4EDC0D426688B295DA77C08 /* ShareLinkRoute.swift in Sources */, 779D1955F350B507A47B1E5B /* ShareLinkShortener.swift in Sources */, AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */, - 740F5EC3331CA9DCCDA682F0 /* SuccessPanel.swift in Sources */, + DDC7994B951A3A7B836B36F6 /* SuccessPanel.swift in Sources */, 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */, F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */, 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */, diff --git a/Crossmate/Views/BundledBrowseView.swift b/Crossmate/Views/Browse/BundledBrowseView.swift diff --git a/Crossmate/Views/CalendarDayCell.swift b/Crossmate/Views/Browse/CalendarDayCell.swift diff --git a/Crossmate/Views/ImportedBrowseView.swift b/Crossmate/Views/Browse/ImportedBrowseView.swift diff --git a/Crossmate/Views/NYTBrowseView.swift b/Crossmate/Views/Browse/NYTBrowseView.swift diff --git a/Crossmate/Views/NYTLoginView.swift b/Crossmate/Views/Browse/NYTLoginView.swift diff --git a/Crossmate/Views/NewGameSheet.swift b/Crossmate/Views/Browse/NewGameSheet.swift diff --git a/Crossmate/Views/AnnouncementBanner.swift b/Crossmate/Views/Components/AnnouncementBanner.swift diff --git a/Crossmate/Views/CompactSlider.swift b/Crossmate/Views/Components/CompactSlider.swift diff --git a/Crossmate/Views/FriendAvatarView.swift b/Crossmate/Views/Components/FriendAvatarView.swift diff --git a/Crossmate/Views/GridThumbnailView.swift b/Crossmate/Views/Components/GridThumbnailView.swift diff --git a/Crossmate/Views/Components/Layouts.swift b/Crossmate/Views/Components/Layouts.swift @@ -0,0 +1,112 @@ +import SwiftUI + +/// Lays subviews left-to-right, wrapping onto a new line when the next +/// subview would overflow the proposed width. Reports the wrapped +/// height for that width so a surrounding `ViewThatFits` can choose +/// between a centred (fits) and a scrolling (overflows) presentation. +struct FlowLayout: Layout { + var spacing: CGFloat = 18 + var lineSpacing: CGFloat = 8 + + private struct Row { + var indices: [Int] = [] + var width: CGFloat = 0 + var height: CGFloat = 0 + } + + private func rows(_ subviews: Subviews, maxWidth: CGFloat) -> [Row] { + var rows: [Row] = [] + var current = Row() + for index in subviews.indices { + let size = subviews[index].sizeThatFits(.unspecified) + let needed = current.indices.isEmpty + ? size.width + : current.width + spacing + size.width + if !current.indices.isEmpty, needed > maxWidth { + rows.append(current) + current = Row(indices: [index], width: size.width, height: size.height) + } else { + if !current.indices.isEmpty { current.width += spacing } + current.indices.append(index) + current.width += size.width + current.height = max(current.height, size.height) + } + } + if !current.indices.isEmpty { rows.append(current) } + return rows + } + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + let rows = rows(subviews, maxWidth: proposal.width ?? .infinity) + let height = rows.reduce(0) { $0 + $1.height } + + lineSpacing * CGFloat(max(0, rows.count - 1)) + let widest = rows.map(\.width).max() ?? 0 + return CGSize(width: proposal.width ?? widest, height: height) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + let rows = rows(subviews, maxWidth: bounds.width) + var y = bounds.minY + for row in rows { + // Centre each row within the available width so a short + // strip (e.g. a single player) sits in the middle. + var x = bounds.minX + max(0, (bounds.width - row.width) / 2) + for index in row.indices { + let size = subviews[index].sizeThatFits(.unspecified) + subviews[index].place( + at: CGPoint(x: x, y: y), + anchor: .topLeading, + proposal: ProposedViewSize(size) + ) + x += size.width + spacing + } + y += row.height + lineSpacing + } + } +} + +struct WeightedVStack: Layout { + let weights: [CGFloat] + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + CGSize( + width: proposal.width ?? 0, + height: proposal.height ?? 0 + ) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + let totalWeight = weights.reduce(0, +) + guard totalWeight > 0 else { return } + + var y = bounds.minY + for (index, subview) in subviews.enumerated() { + let weight = index < weights.count ? weights[index] : 0 + let height = bounds.height * weight / totalWeight + subview.place( + at: CGPoint(x: bounds.minX, y: y), + anchor: .topLeading, + proposal: ProposedViewSize(width: bounds.width, height: height) + ) + y += height + } + } +} diff --git a/Crossmate/Views/FriendPickerView.swift b/Crossmate/Views/Friends/FriendPickerView.swift diff --git a/Crossmate/Views/FriendsView.swift b/Crossmate/Views/Friends/FriendsView.swift diff --git a/Crossmate/Views/GameList/GameCardView.swift b/Crossmate/Views/GameList/GameCardView.swift @@ -0,0 +1,208 @@ +import SwiftUI + +// MARK: - Card (regular width) + +enum CardMetrics { + static let height: CGFloat = 88 + static let cornerRadius: CGFloat = 12 +} + +/// Tappable card used in the iPad grid layout. The whole card is one +/// `Button` (so the pressed-state highlight covers the full card), and the +/// overflow `Menu` is layered as an `.overlay` rather than nested inside the +/// button — keeping them siblings means tapping the ellipsis opens the menu +/// instead of also firing the navigation action. +struct GameCardView: View { + let game: GameSummary + let shareController: ShareController + let usesRoomierType: Bool + var onResume: () -> Void = {} + var onLeave: () -> Void = {} + var onResign: () -> Void = {} + var onDelete: () -> Void = {} + @State private var isShowingShareSheet = false + + var body: some View { + let showsUnreadBadge = game.hasUnreadOtherMoves + + Button(action: onResume) { + HStack(spacing: 12) { + GridThumbnailView( + width: game.gridWidth, + height: game.gridHeight, + cells: game.thumbnailCells + ) + .overlay(alignment: .topTrailing) { + if showsUnreadBadge { + Circle() + .fill(.red) + .frame(width: 14, height: 14) + .overlay( + Circle() + .stroke(.background, lineWidth: 2) + ) + .offset(x: 5, y: -5) + .accessibilityLabel("Unseen changes") + } + } + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(game.title) + .font((usesRoomierType ? Font.headline : .subheadline).weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.8) + .truncationMode(.tail) + if game.isShared { + Image(systemName: "person.2.fill") + .font(.caption) + .foregroundStyle(.secondary) + } + } + GameMetadataView( + puzzleDate: game.puzzleDate, + publisher: game.publisher, + usesRoomierType: usesRoomierType + ) + if let date = game.updatedAt { + LastUpdatedView(date: date, usesRoomierType: usesRoomierType) + } + } + Spacer(minLength: 0) + // Reserve room for the overflow menu, which is layered as an + // overlay so its taps don't fall through to this button. + Color.clear.frame(width: 32, height: 32) + } + .padding(12) + .frame(maxWidth: .infinity) + .frame(height: CardMetrics.height) + } + .buttonStyle(CardButtonStyle()) + .overlay(alignment: .trailing) { + GameOverflowMenu( + game: game, + onShare: { isShowingShareSheet = true }, + onResume: onResume, + onLeave: onLeave, + onResign: onResign, + onDelete: onDelete + ) + .padding(.trailing, 12) + } + .sheet(isPresented: $isShowingShareSheet) { + GameShareSheet( + gameID: game.id, + title: game.title, + shareController: shareController + ) + } + } +} + +private struct CardButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background( + Color(.secondarySystemGroupedBackground), + in: RoundedRectangle(cornerRadius: CardMetrics.cornerRadius) + ) + .overlay { + if configuration.isPressed { + RoundedRectangle(cornerRadius: CardMetrics.cornerRadius) + .fill(Color.primary.opacity(0.06)) + } + } + .contentShape(RoundedRectangle(cornerRadius: CardMetrics.cornerRadius)) + } +} + +// MARK: - Shared overflow menu + +struct GameOverflowMenu: View { + let game: GameSummary + var onShare: () -> Void + var onResume: () -> Void + var onLeave: () -> Void + var onResign: () -> Void + var onDelete: () -> Void + + var body: some View { + Menu { + Button { onShare() } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + .disabled(!game.isOwned) + Button { onResume() } label: { + Label("Resume", systemImage: "square.and.pencil") + } + Section { + Button(role: .destructive) { onResign() } label: { + Label("Resign", systemImage: "flag") + } + if !game.isOwned && game.isShared { + Button(role: .destructive) { onLeave() } label: { + Label("Leave", systemImage: "rectangle.portrait.and.arrow.right") + } + } else { + Button(role: .destructive) { onDelete() } label: { + Label("Delete", systemImage: "trash") + } + } + } + } label: { + Image(systemName: "ellipsis") + .font(.body) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .tint(.secondary) + .compositingGroup() + } +} + +struct GameMetadataView: View { + let puzzleDate: Date? + let publisher: String? + let usesRoomierType: Bool + + private var font: Font { + usesRoomierType ? .subheadline : .footnote + } + + var body: some View { + if let puzzleDate, let publisher { + ViewThatFits(in: .horizontal) { + HStack(spacing: 0) { + Text(puzzleDate, format: .dateTime.day().month(.abbreviated).year()) + Text(" • ") + Text(publisher) + } + .font(font) + .lineLimit(1) + + VStack(alignment: .leading, spacing: 2) { + puzzleDateView(puzzleDate) + publisherView(publisher) + } + } + } else { + if let puzzleDate { + puzzleDateView(puzzleDate) + } + if let publisher { + publisherView(publisher) + } + } + } + + private func puzzleDateView(_ puzzleDate: Date) -> some View { + Text(puzzleDate, format: .dateTime.day().month(.abbreviated).year()) + .font(font) + } + + private func publisherView(_ publisher: String) -> some View { + Text(publisher) + .font(font) + .lineLimit(1) + .truncationMode(.tail) + } +} diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift @@ -0,0 +1,665 @@ +import CoreData +import SwiftUI + +struct GameListView: View { + let store: GameStore + let shareController: ShareController + let onRefresh: () async -> Void + let onAppear: () async -> Void + let onDisappear: () -> Void + @Binding var navigationPath: NavigationPath + + @Environment(\.managedObjectContext) private var viewContext + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @FetchRequest( + sortDescriptors: [], + animation: .default + ) + private var games: FetchedResults<GameEntity> + + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \InviteEntity.createdAt, ascending: true)], + predicate: NSPredicate(format: "status == %@", "pending"), + animation: .default + ) + private var pendingInvites: FetchedResults<InviteEntity> + + @FetchRequest( + sortDescriptors: [], + predicate: NSPredicate(format: "isBlocked == YES") + ) + private var blockedFriends: FetchedResults<FriendEntity> + + @Environment(\.acceptInvite) private var acceptInvite + @Environment(\.declineInvite) private var declineInvite + @Environment(\.blockFriend) private var blockFriend + @Environment(\.sendResignPings) private var sendResignPings + @Environment(PlayerPreferences.self) private var preferences + @Environment(AnnouncementCenter.self) private var announcements + @State private var acceptingInviteID: NSManagedObjectID? + @State private var blockTarget: InviteEntity? + + @State private var showingNewGame = false + @State private var showingSettings = false + @State private var showingFriends = false + @State private var deleteTarget: GameSummary? + @State private var resignTarget: GameSummary? + @State private var leaveTarget: GameSummary? + @State private var leaveError: Error? + @State private var showingNamePrompt = false + @State private var nameDraft = "" + @State private var summaryCache = GameSummaryCache() + @State private var completedVisibleCount = completedPageSize + + private static let completedPageSize = 7 + + var body: some View { + GeometryReader { geometry in + VStack(spacing: 0) { + if let announcement = announcements.currentGlobal() { + AnnouncementBanner(announcement: announcement) { + announcements.dismiss(id: announcement.id) + } + .padding(.horizontal) + .padding(.top, 8) + .transition(.move(edge: .top).combined(with: .opacity)) + } + content(usesRoomierType: usesRoomierType(for: geometry.size)) + } + .animation(.easeInOut(duration: 0.3), value: announcements.currentGlobal()) + } + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + showingSettings = true + } label: { + Image(systemName: "gearshape") + } + } + ToolbarItem(placement: .topBarTrailing) { + Button { + showingFriends = true + } label: { + Image(systemName: "person.2") + } + } + ToolbarSpacer(.fixed, placement: .topBarTrailing) + ToolbarItem(placement: .topBarTrailing) { + Button { + showingNewGame = true + } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showingSettings) { + SettingsView() + } + .sheet(isPresented: $showingFriends) { + FriendsView() + } + .sheet(isPresented: $showingNewGame) { + NewGameSheet(store: store) { gameID in + navigationPath.append(gameID) + } + } + .task { + await onAppear() + } + .onDisappear { + onDisappear() + } + .alert("Resign Puzzle?", isPresented: .init( + get: { resignTarget != nil }, + set: { if !$0 { resignTarget = nil } } + )) { + Button("Resign", role: .destructive) { + if let target = resignTarget { + do { + try store.resignGame(id: target.id) + let id = target.id + Task { await sendResignPings?(id) } + } catch { + announcements.post(Announcement( + id: Self.destructiveActionErrorID, + scope: .global, + severity: .error, + title: "Resigning Failed", + body: error.localizedDescription, + dismissal: .manual + )) + } + } + } + Button("Cancel", role: .cancel) {} + } message: { + if let target = resignTarget { + Text("This will reveal all answers for \"\(target.title)\".") + } + } + .alert("Leave Puzzle?", isPresented: .init( + get: { leaveTarget != nil }, + set: { if !$0 { leaveTarget = nil } } + )) { + Button("Leave", role: .destructive) { + if let target = leaveTarget { + Task { await leaveShare(game: target) } + } + } + Button("Cancel", role: .cancel) {} + } message: { + if let target = leaveTarget { + Text("You will lose access to \"\(target.title)\".") + } + } + .alert("Delete Puzzle?", isPresented: .init( + get: { deleteTarget != nil }, + set: { if !$0 { deleteTarget = nil } } + )) { + Button("Delete", role: .destructive) { + if let target = deleteTarget { + do { + try store.deleteGame(id: target.id) + } catch { + announcements.post(Announcement( + id: Self.destructiveActionErrorID, + scope: .global, + severity: .error, + title: "Deleting Failed", + body: error.localizedDescription, + dismissal: .manual + )) + } + } + } + Button("Cancel", role: .cancel) {} + } message: { + if let target = deleteTarget { + if target.isOwned && target.isShared { + Text("This will permanently delete \"\(target.title)\" from iCloud for everyone.") + } else { + Text("This will permanently delete \"\(target.title)\" and all progress.") + } + } + } + .alert("Block This Player?", isPresented: .init( + get: { blockTarget != nil }, + set: { if !$0 { blockTarget = nil } } + )) { + Button("Block", role: .destructive) { + if let target = blockTarget, let authorID = target.inviterAuthorID { + Task { await blockFriend?(authorID) } + } + } + Button("Cancel", role: .cancel) {} + } message: { + let name = blockTarget?.resolvedInviterName ?? "this player" + Text("You won't receive further invites from \(name), and any games they currently share with you will be removed from this device.") + } + .alert("Set Profile Name", isPresented: $showingNamePrompt) { + TextField("Name", text: $nameDraft) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Button("Cancel", role: .cancel) {} + Button("Save") { + let trimmed = nameDraft.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + preferences.name = trimmed + nameDraft = trimmed + } + } + .keyboardShortcut(.defaultAction) + } message: { + Text("Enter the name other players will see.") + } + } + + @ViewBuilder + private func content(usesRoomierType: Bool) -> some View { + let summaries = games.compactMap { summaryCache.summary(for: $0) } + let inProgress = summaries + .filter { $0.completedAt == nil && !$0.isAccessRevoked } + .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } + let revoked = summaries + .filter { $0.isAccessRevoked } + .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } + let completed = summaries + .filter { $0.completedAt != nil && !$0.isAccessRevoked } + .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) } + let visibleCount = min(completedVisibleCount, completed.count) + let visibleCompleted = Array(completed.prefix(visibleCount)) + let hasMore = visibleCount < completed.count + + let blockedIDs = Set(blockedFriends.compactMap { $0.authorID }) + let visibleInvites = pendingInvites.filter { + guard let inviter = $0.inviterAuthorID else { return true } + return !blockedIDs.contains(inviter) + } + + Group { + if horizontalSizeClass == .regular { + gridLayout( + invites: visibleInvites, + inProgress: inProgress, + revoked: revoked, + completed: visibleCompleted, + hasMore: hasMore, + usesRoomierType: usesRoomierType + ) + } else { + listLayout( + invites: visibleInvites, + inProgress: inProgress, + revoked: revoked, + completed: visibleCompleted, + hasMore: hasMore, + usesRoomierType: usesRoomierType + ) + } + } + .overlay { + if games.isEmpty { + if preferences.hasName { + ContentUnavailableView { + Label("No Puzzles", systemImage: "square.grid.3x3") + } description: { + Text("Tap the + button to start a new puzzle, or pull down to refresh.") + } + } else { + ContentUnavailableView { + Label("Set Your Profile Name", systemImage: "person.text.rectangle") + } description: { + Text("Choose the name other players will see.") + } actions: { + Button { + nameDraft = "" + showingNamePrompt = true + } label: { Text("Set Profile Name") } + .buttonStyle(.borderedProminent) + } + } + } + } + .onChange(of: completed.count) { oldCount, newCount in + if newCount > oldCount { + completedVisibleCount += (newCount - oldCount) + } + } + } + + // MARK: - List layout (compact width / iPhone) + + @ViewBuilder + private func listLayout( + invites: [InviteEntity], + inProgress: [GameSummary], + revoked: [GameSummary], + completed: [GameSummary], + hasMore: Bool, + usesRoomierType: Bool + ) -> some View { + List { + if !invites.isEmpty { + Section { + ForEach(invites, id: \.objectID) { invite in + inviteRow(for: invite) + } + } header: { + Text("Invited") + } + } + + if !inProgress.isEmpty { + Section { + ForEach(inProgress) { game in + rowView(for: game, usesRoomierType: usesRoomierType) + } + } header: { + Text("In Progress") + } + } + + if !revoked.isEmpty { + Section { + ForEach(revoked) { game in + rowView(for: game, usesRoomierType: usesRoomierType) + } + } header: { + Text("Revoked") + } + } + + if !completed.isEmpty { + Section { + ForEach(completed) { game in + rowView(for: game, usesRoomierType: usesRoomierType) + } + } header: { + Text("Completed") + } footer: { + if hasMore { + loadMoreButton + } + } + } + } + .refreshable { + await onRefresh() + } + } + + // MARK: - Grid layout (regular width / iPad) + + private var gridColumns: [GridItem] { + [GridItem(.adaptive(minimum: 320), spacing: 12)] + } + + @ViewBuilder + private func gridLayout( + invites: [InviteEntity], + inProgress: [GameSummary], + revoked: [GameSummary], + completed: [GameSummary], + hasMore: Bool, + usesRoomierType: Bool + ) -> some View { + ScrollView { + LazyVStack(spacing: 8) { + if !invites.isEmpty { + Section { + LazyVGrid(columns: gridColumns, spacing: 12) { + ForEach(invites, id: \.objectID) { invite in + inviteCard(for: invite) + } + } + .padding(.horizontal) + } header: { + gridSectionHeader("Invited") + } + } + + if !inProgress.isEmpty { + Section { + LazyVGrid(columns: gridColumns, spacing: 12) { + ForEach(inProgress) { game in + gameCard(for: game, usesRoomierType: usesRoomierType) + } + } + .padding(.horizontal) + } header: { + gridSectionHeader("In Progress") + } + } + + if !revoked.isEmpty { + Section { + LazyVGrid(columns: gridColumns, spacing: 12) { + ForEach(revoked) { game in + gameCard(for: game, usesRoomierType: usesRoomierType) + } + } + .padding(.horizontal) + } header: { + gridSectionHeader("Revoked") + } + } + + if !completed.isEmpty { + Section { + LazyVGrid(columns: gridColumns, spacing: 12) { + ForEach(completed) { game in + gameCard(for: game, usesRoomierType: usesRoomierType) + } + } + .padding(.horizontal) + + if hasMore { + loadMoreButton + .padding(.horizontal) + } + } header: { + gridSectionHeader("Completed") + } + } + } + .padding(.vertical, 8) + } + .background(Color(.systemGroupedBackground)) + .refreshable { + await onRefresh() + } + } + + private func gridSectionHeader(_ title: String) -> some View { + Text(title) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color(.systemGroupedBackground)) + } + + private var loadMoreButton: some View { + HStack { + Spacer() + Button { + withAnimation(.easeInOut(duration: 0.25)) { + completedVisibleCount += Self.completedPageSize + } + } label: { + Text("Load More") + .font(.subheadline.weight(.semibold)) + .foregroundColor(.secondary) + .padding(.horizontal, 18) + .padding(.vertical, 8) + .background(Color(.tertiarySystemFill), in: Capsule()) + } + .buttonStyle(.plain) + .textCase(nil) + Spacer() + } + .padding(.top, 8) + } + + private func gameCard(for game: GameSummary, usesRoomierType: Bool) -> some View { + GameCardView( + game: game, + shareController: shareController, + usesRoomierType: usesRoomierType, + onResume: { navigationPath.append(game.id) }, + onLeave: { leaveTarget = game }, + onResign: { resignTarget = game }, + onDelete: { deleteTarget = game } + ) + } + + /// The puzzle-shape preview for an invite, decoded from the silhouette + /// segment the inviter sent. Open cells render grey (`.filled`) to read as + /// "not yet playable", matching the link-tap placeholder in + /// `JoiningPuzzleView`. Absent or non-square grids get no thumbnail. + @ViewBuilder + private func inviteThumbnail(for invite: InviteEntity) -> some View { + if let segment = invite.gridSilhouette, + let shape = GridSilhouette.decode(segment) { + GridThumbnailView( + width: shape.side, + height: shape.side, + cells: shape.blocks.map { $0 ? .block : .filled } + ) + } + } + + @ViewBuilder + private func inviteCard(for invite: InviteEntity) -> some View { + let inviter = invite.resolvedInviterName ?? "A player" + let title = (invite.gameTitle?.isEmpty == false) ? invite.gameTitle! : "a puzzle" + HStack(spacing: 12) { + inviteThumbnail(for: invite) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + .truncationMode(.tail) + Text("Invited by \(inviter)") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 0) + if acceptingInviteID == invite.objectID { + ProgressView() + } else { + Button("Accept") { Task { await accept(invite) } } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + inviteMenu(for: invite) + } + .padding(12) + .frame(maxWidth: .infinity) + .frame(height: CardMetrics.height) + .background( + Color(.secondarySystemGroupedBackground), + in: RoundedRectangle(cornerRadius: CardMetrics.cornerRadius) + ) + } + + private func inviteMenu(for invite: InviteEntity) -> some View { + Menu { + Button { Task { await decline(invite) } } label: { + Label("Decline", systemImage: "xmark") + } + Button(role: .destructive) { blockTarget = invite } label: { + Label("Block", systemImage: "hand.raised") + } + } label: { + Text("More") + .foregroundStyle(.primary) + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(.secondary) + .compositingGroup() + } + + @ViewBuilder + private func inviteRow(for invite: InviteEntity) -> some View { + let inviter = invite.resolvedInviterName ?? "A player" + let title = (invite.gameTitle?.isEmpty == false) ? invite.gameTitle! : "a puzzle" + HStack { + inviteThumbnail(for: invite) + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.body.weight(.medium)) + Text("Invited by \(inviter)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if acceptingInviteID == invite.objectID { + ProgressView() + } else { + Button("Accept") { Task { await accept(invite) } } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + inviteMenu(for: invite) + } + .swipeActions(edge: .trailing) { + Button("Decline") { Task { await decline(invite) } } + .tint(.gray) + Button("Block", role: .destructive) { blockTarget = invite } + } + } + + private func accept(_ invite: InviteEntity) async { + guard let acceptInvite, + let url = invite.shareURL, + let ping = invite.pingRecordName + else { return } + acceptingInviteID = invite.objectID + announcements.dismiss(id: Self.inviteErrorID) + defer { acceptingInviteID = nil } + do { + try await acceptInvite(url, ping) + } catch { + announcements.post(Announcement( + id: Self.inviteErrorID, + scope: .global, + severity: .error, + title: "Accepting Failed", + body: error.localizedDescription, + dismissal: .manual + )) + } + } + + /// Single-slot id for the invite-accept failure banner — a fresh + /// failure replaces the prior one rather than stacking. + private static let inviteErrorID = "invite-accept-error" + + /// Single-slot id for game-list destructive-action failures (decline, + /// resign, delete) — a fresh failure replaces the prior one. + private static let destructiveActionErrorID = "game-list-destructive-action-error" + + private func decline(_ invite: InviteEntity) async { + guard let declineInvite, let gameID = invite.gameID else { return } + do { + try await declineInvite(gameID) + } catch { + announcements.post(Announcement( + id: Self.destructiveActionErrorID, + scope: .global, + severity: .error, + title: "Declining Failed", + body: error.localizedDescription, + dismissal: .manual + )) + } + } + + @ViewBuilder + private func rowView(for game: GameSummary, usesRoomierType: Bool) -> some View { + GameRowView( + game: game, + shareController: shareController, + usesRoomierType: usesRoomierType, + onResume: { navigationPath.append(game.id) }, + onLeave: { leaveTarget = game }, + onResign: { resignTarget = game }, + onDelete: { deleteTarget = game } + ) + .background( + NavigationLink(value: game.id) { EmptyView() } + .opacity(0) + ) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if !game.isOwned && game.isShared { + Button("Leave", role: .destructive) { + leaveTarget = game + } + } else { + Button("Delete", role: .destructive) { + deleteTarget = game + } + } + } + } + + private func leaveShare(game: GameSummary) async { + do { + try await shareController.leaveShare(gameID: game.id) + leaveTarget = nil + } catch { + leaveError = error + leaveTarget = nil + } + } + + private func usesRoomierType(for size: CGSize) -> Bool { + size.height >= 760 && dynamicTypeSize <= .medium + } +} diff --git a/Crossmate/Views/GameList/GameRowView.swift b/Crossmate/Views/GameList/GameRowView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +// MARK: - Row + +struct GameRowView: View { + let game: GameSummary + let shareController: ShareController + let usesRoomierType: Bool + var onResume: () -> Void = {} + var onLeave: () -> Void = {} + var onResign: () -> Void = {} + var onDelete: () -> Void = {} + @State private var isShowingShareSheet = false + + var body: some View { + let showsUnreadBadge = game.hasUnreadOtherMoves + + HStack(spacing: 12) { + GridThumbnailView( + width: game.gridWidth, + height: game.gridHeight, + cells: game.thumbnailCells + ) + .overlay(alignment: .topTrailing) { + if showsUnreadBadge { + Circle() + .fill(.red) + .frame(width: 14, height: 14) + .overlay( + Circle() + .stroke(.background, lineWidth: 2) + ) + .offset(x: 5, y: -5) + .accessibilityLabel("Unseen changes") + } + } + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(game.title) + .font((usesRoomierType ? Font.headline : .subheadline).weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.8) + .truncationMode(.tail) + if game.isShared { + Image(systemName: "person.2.fill") + .font(.caption) + .foregroundStyle(.secondary) + } + } + GameMetadataView( + puzzleDate: game.puzzleDate, + publisher: game.publisher, + usesRoomierType: usesRoomierType + ) + if let date = game.updatedAt { + LastUpdatedView(date: date, usesRoomierType: usesRoomierType) + } + } + Spacer() + GameOverflowMenu( + game: game, + onShare: { isShowingShareSheet = true }, + onResume: onResume, + onLeave: onLeave, + onResign: onResign, + onDelete: onDelete + ) + } + .padding(.vertical, 4) + .sheet(isPresented: $isShowingShareSheet) { + GameShareSheet( + gameID: game.id, + title: game.title, + shareController: shareController + ) + } + } +} + diff --git a/Crossmate/Views/GameShareItem.swift b/Crossmate/Views/GameList/GameShareItem.swift diff --git a/Crossmate/Views/GameList/LastUpdatedView.swift b/Crossmate/Views/GameList/LastUpdatedView.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct LastUpdatedView: View { + let date: Date + let usesRoomierType: Bool + + var body: some View { + TimelineView(.lastUpdated(from: date)) { context in + Text(text(now: context.date)) + .font(usesRoomierType ? .footnote : .caption) + .foregroundStyle(.secondary) + } + } + + private func text(now: Date) -> String { + let elapsed = max(0, now.timeIntervalSince(date)) + if elapsed < 60 { + let seconds = max(1, Int(elapsed.rounded(.down))) + return "Last updated \(seconds) \(seconds == 1 ? "second" : "seconds") ago" + } + if elapsed < 60 * 60 { + let minutes = Int((elapsed / 60).rounded(.down)) + return "Last updated \(minutes) \(minutes == 1 ? "minute" : "minutes") ago" + } + if elapsed <= 48 * 60 * 60 { + let hours = Int((elapsed / (60 * 60)).rounded(.down)) + return "Last updated \(hours) \(hours == 1 ? "hour" : "hours") ago" + } + return "Last updated on \(date.formatted(.dateTime.day().month(.abbreviated).year()))" + } +} + +private struct LastUpdatedSchedule: TimelineSchedule { + let anchor: Date + + func entries(from startDate: Date, mode: TimelineScheduleMode) -> AnyIterator<Date> { + var next = startDate + return AnyIterator { + let current = next + let elapsed = max(0, current.timeIntervalSince(anchor)) + let step: TimeInterval + if elapsed < 60 { + step = 1 + } else if elapsed < 60 * 60 { + step = 60 + } else if elapsed <= 48 * 60 * 60 { + step = 60 * 60 + } else { + return nil + } + next = current.addingTimeInterval(step) + return current + } + } +} + +extension TimelineSchedule where Self == LastUpdatedSchedule { + static func lastUpdated(from date: Date) -> LastUpdatedSchedule { + LastUpdatedSchedule(anchor: date) + } +} + diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift @@ -1,1009 +0,0 @@ -import CoreData -import SwiftUI - -struct GameListView: View { - let store: GameStore - let shareController: ShareController - let onRefresh: () async -> Void - let onAppear: () async -> Void - let onDisappear: () -> Void - @Binding var navigationPath: NavigationPath - - @Environment(\.managedObjectContext) private var viewContext - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @FetchRequest( - sortDescriptors: [], - animation: .default - ) - private var games: FetchedResults<GameEntity> - - @FetchRequest( - sortDescriptors: [NSSortDescriptor(keyPath: \InviteEntity.createdAt, ascending: true)], - predicate: NSPredicate(format: "status == %@", "pending"), - animation: .default - ) - private var pendingInvites: FetchedResults<InviteEntity> - - @FetchRequest( - sortDescriptors: [], - predicate: NSPredicate(format: "isBlocked == YES") - ) - private var blockedFriends: FetchedResults<FriendEntity> - - @Environment(\.acceptInvite) private var acceptInvite - @Environment(\.declineInvite) private var declineInvite - @Environment(\.blockFriend) private var blockFriend - @Environment(\.sendResignPings) private var sendResignPings - @Environment(PlayerPreferences.self) private var preferences - @Environment(AnnouncementCenter.self) private var announcements - @State private var acceptingInviteID: NSManagedObjectID? - @State private var blockTarget: InviteEntity? - - @State private var showingNewGame = false - @State private var showingSettings = false - @State private var showingFriends = false - @State private var deleteTarget: GameSummary? - @State private var resignTarget: GameSummary? - @State private var leaveTarget: GameSummary? - @State private var leaveError: Error? - @State private var showingNamePrompt = false - @State private var nameDraft = "" - @State private var summaryCache = GameSummaryCache() - @State private var completedVisibleCount = completedPageSize - - private static let completedPageSize = 7 - - var body: some View { - GeometryReader { geometry in - VStack(spacing: 0) { - if let announcement = announcements.currentGlobal() { - AnnouncementBanner(announcement: announcement) { - announcements.dismiss(id: announcement.id) - } - .padding(.horizontal) - .padding(.top, 8) - .transition(.move(edge: .top).combined(with: .opacity)) - } - content(usesRoomierType: usesRoomierType(for: geometry.size)) - } - .animation(.easeInOut(duration: 0.3), value: announcements.currentGlobal()) - } - .navigationTitle("") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { - showingSettings = true - } label: { - Image(systemName: "gearshape") - } - } - ToolbarItem(placement: .topBarTrailing) { - Button { - showingFriends = true - } label: { - Image(systemName: "person.2") - } - } - ToolbarSpacer(.fixed, placement: .topBarTrailing) - ToolbarItem(placement: .topBarTrailing) { - Button { - showingNewGame = true - } label: { - Image(systemName: "plus") - } - } - } - .sheet(isPresented: $showingSettings) { - SettingsView() - } - .sheet(isPresented: $showingFriends) { - FriendsView() - } - .sheet(isPresented: $showingNewGame) { - NewGameSheet(store: store) { gameID in - navigationPath.append(gameID) - } - } - .task { - await onAppear() - } - .onDisappear { - onDisappear() - } - .alert("Resign Puzzle?", isPresented: .init( - get: { resignTarget != nil }, - set: { if !$0 { resignTarget = nil } } - )) { - Button("Resign", role: .destructive) { - if let target = resignTarget { - do { - try store.resignGame(id: target.id) - let id = target.id - Task { await sendResignPings?(id) } - } catch { - announcements.post(Announcement( - id: Self.destructiveActionErrorID, - scope: .global, - severity: .error, - title: "Resigning Failed", - body: error.localizedDescription, - dismissal: .manual - )) - } - } - } - Button("Cancel", role: .cancel) {} - } message: { - if let target = resignTarget { - Text("This will reveal all answers for \"\(target.title)\".") - } - } - .alert("Leave Puzzle?", isPresented: .init( - get: { leaveTarget != nil }, - set: { if !$0 { leaveTarget = nil } } - )) { - Button("Leave", role: .destructive) { - if let target = leaveTarget { - Task { await leaveShare(game: target) } - } - } - Button("Cancel", role: .cancel) {} - } message: { - if let target = leaveTarget { - Text("You will lose access to \"\(target.title)\".") - } - } - .alert("Delete Puzzle?", isPresented: .init( - get: { deleteTarget != nil }, - set: { if !$0 { deleteTarget = nil } } - )) { - Button("Delete", role: .destructive) { - if let target = deleteTarget { - do { - try store.deleteGame(id: target.id) - } catch { - announcements.post(Announcement( - id: Self.destructiveActionErrorID, - scope: .global, - severity: .error, - title: "Deleting Failed", - body: error.localizedDescription, - dismissal: .manual - )) - } - } - } - Button("Cancel", role: .cancel) {} - } message: { - if let target = deleteTarget { - if target.isOwned && target.isShared { - Text("This will permanently delete \"\(target.title)\" from iCloud for everyone.") - } else { - Text("This will permanently delete \"\(target.title)\" and all progress.") - } - } - } - .alert("Block This Player?", isPresented: .init( - get: { blockTarget != nil }, - set: { if !$0 { blockTarget = nil } } - )) { - Button("Block", role: .destructive) { - if let target = blockTarget, let authorID = target.inviterAuthorID { - Task { await blockFriend?(authorID) } - } - } - Button("Cancel", role: .cancel) {} - } message: { - let name = blockTarget?.resolvedInviterName ?? "this player" - Text("You won't receive further invites from \(name), and any games they currently share with you will be removed from this device.") - } - .alert("Set Profile Name", isPresented: $showingNamePrompt) { - TextField("Name", text: $nameDraft) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - Button("Cancel", role: .cancel) {} - Button("Save") { - let trimmed = nameDraft.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - preferences.name = trimmed - nameDraft = trimmed - } - } - .keyboardShortcut(.defaultAction) - } message: { - Text("Enter the name other players will see.") - } - } - - @ViewBuilder - private func content(usesRoomierType: Bool) -> some View { - let summaries = games.compactMap { summaryCache.summary(for: $0) } - let inProgress = summaries - .filter { $0.completedAt == nil && !$0.isAccessRevoked } - .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } - let revoked = summaries - .filter { $0.isAccessRevoked } - .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } - let completed = summaries - .filter { $0.completedAt != nil && !$0.isAccessRevoked } - .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) } - let visibleCount = min(completedVisibleCount, completed.count) - let visibleCompleted = Array(completed.prefix(visibleCount)) - let hasMore = visibleCount < completed.count - - let blockedIDs = Set(blockedFriends.compactMap { $0.authorID }) - let visibleInvites = pendingInvites.filter { - guard let inviter = $0.inviterAuthorID else { return true } - return !blockedIDs.contains(inviter) - } - - Group { - if horizontalSizeClass == .regular { - gridLayout( - invites: visibleInvites, - inProgress: inProgress, - revoked: revoked, - completed: visibleCompleted, - hasMore: hasMore, - usesRoomierType: usesRoomierType - ) - } else { - listLayout( - invites: visibleInvites, - inProgress: inProgress, - revoked: revoked, - completed: visibleCompleted, - hasMore: hasMore, - usesRoomierType: usesRoomierType - ) - } - } - .overlay { - if games.isEmpty { - if preferences.hasName { - ContentUnavailableView { - Label("No Puzzles", systemImage: "square.grid.3x3") - } description: { - Text("Tap the + button to start a new puzzle, or pull down to refresh.") - } - } else { - ContentUnavailableView { - Label("Set Your Profile Name", systemImage: "person.text.rectangle") - } description: { - Text("Choose the name other players will see.") - } actions: { - Button { - nameDraft = "" - showingNamePrompt = true - } label: { Text("Set Profile Name") } - .buttonStyle(.borderedProminent) - } - } - } - } - .onChange(of: completed.count) { oldCount, newCount in - if newCount > oldCount { - completedVisibleCount += (newCount - oldCount) - } - } - } - - // MARK: - List layout (compact width / iPhone) - - @ViewBuilder - private func listLayout( - invites: [InviteEntity], - inProgress: [GameSummary], - revoked: [GameSummary], - completed: [GameSummary], - hasMore: Bool, - usesRoomierType: Bool - ) -> some View { - List { - if !invites.isEmpty { - Section { - ForEach(invites, id: \.objectID) { invite in - inviteRow(for: invite) - } - } header: { - Text("Invited") - } - } - - if !inProgress.isEmpty { - Section { - ForEach(inProgress) { game in - rowView(for: game, usesRoomierType: usesRoomierType) - } - } header: { - Text("In Progress") - } - } - - if !revoked.isEmpty { - Section { - ForEach(revoked) { game in - rowView(for: game, usesRoomierType: usesRoomierType) - } - } header: { - Text("Revoked") - } - } - - if !completed.isEmpty { - Section { - ForEach(completed) { game in - rowView(for: game, usesRoomierType: usesRoomierType) - } - } header: { - Text("Completed") - } footer: { - if hasMore { - loadMoreButton - } - } - } - } - .refreshable { - await onRefresh() - } - } - - // MARK: - Grid layout (regular width / iPad) - - private var gridColumns: [GridItem] { - [GridItem(.adaptive(minimum: 320), spacing: 12)] - } - - @ViewBuilder - private func gridLayout( - invites: [InviteEntity], - inProgress: [GameSummary], - revoked: [GameSummary], - completed: [GameSummary], - hasMore: Bool, - usesRoomierType: Bool - ) -> some View { - ScrollView { - LazyVStack(spacing: 8) { - if !invites.isEmpty { - Section { - LazyVGrid(columns: gridColumns, spacing: 12) { - ForEach(invites, id: \.objectID) { invite in - inviteCard(for: invite) - } - } - .padding(.horizontal) - } header: { - gridSectionHeader("Invited") - } - } - - if !inProgress.isEmpty { - Section { - LazyVGrid(columns: gridColumns, spacing: 12) { - ForEach(inProgress) { game in - gameCard(for: game, usesRoomierType: usesRoomierType) - } - } - .padding(.horizontal) - } header: { - gridSectionHeader("In Progress") - } - } - - if !revoked.isEmpty { - Section { - LazyVGrid(columns: gridColumns, spacing: 12) { - ForEach(revoked) { game in - gameCard(for: game, usesRoomierType: usesRoomierType) - } - } - .padding(.horizontal) - } header: { - gridSectionHeader("Revoked") - } - } - - if !completed.isEmpty { - Section { - LazyVGrid(columns: gridColumns, spacing: 12) { - ForEach(completed) { game in - gameCard(for: game, usesRoomierType: usesRoomierType) - } - } - .padding(.horizontal) - - if hasMore { - loadMoreButton - .padding(.horizontal) - } - } header: { - gridSectionHeader("Completed") - } - } - } - .padding(.vertical, 8) - } - .background(Color(.systemGroupedBackground)) - .refreshable { - await onRefresh() - } - } - - private func gridSectionHeader(_ title: String) -> some View { - Text(title) - .font(.footnote.weight(.semibold)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Color(.systemGroupedBackground)) - } - - private var loadMoreButton: some View { - HStack { - Spacer() - Button { - withAnimation(.easeInOut(duration: 0.25)) { - completedVisibleCount += Self.completedPageSize - } - } label: { - Text("Load More") - .font(.subheadline.weight(.semibold)) - .foregroundColor(.secondary) - .padding(.horizontal, 18) - .padding(.vertical, 8) - .background(Color(.tertiarySystemFill), in: Capsule()) - } - .buttonStyle(.plain) - .textCase(nil) - Spacer() - } - .padding(.top, 8) - } - - private func gameCard(for game: GameSummary, usesRoomierType: Bool) -> some View { - GameCardView( - game: game, - shareController: shareController, - usesRoomierType: usesRoomierType, - onResume: { navigationPath.append(game.id) }, - onLeave: { leaveTarget = game }, - onResign: { resignTarget = game }, - onDelete: { deleteTarget = game } - ) - } - - /// The puzzle-shape preview for an invite, decoded from the silhouette - /// segment the inviter sent. Open cells render grey (`.filled`) to read as - /// "not yet playable", matching the link-tap placeholder in - /// `JoiningPuzzleView`. Absent or non-square grids get no thumbnail. - @ViewBuilder - private func inviteThumbnail(for invite: InviteEntity) -> some View { - if let segment = invite.gridSilhouette, - let shape = GridSilhouette.decode(segment) { - GridThumbnailView( - width: shape.side, - height: shape.side, - cells: shape.blocks.map { $0 ? .block : .filled } - ) - } - } - - @ViewBuilder - private func inviteCard(for invite: InviteEntity) -> some View { - let inviter = invite.resolvedInviterName ?? "A player" - let title = (invite.gameTitle?.isEmpty == false) ? invite.gameTitle! : "a puzzle" - HStack(spacing: 12) { - inviteThumbnail(for: invite) - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.subheadline.weight(.semibold)) - .lineLimit(1) - .truncationMode(.tail) - Text("Invited by \(inviter)") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Spacer(minLength: 0) - if acceptingInviteID == invite.objectID { - ProgressView() - } else { - Button("Accept") { Task { await accept(invite) } } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } - inviteMenu(for: invite) - } - .padding(12) - .frame(maxWidth: .infinity) - .frame(height: CardMetrics.height) - .background( - Color(.secondarySystemGroupedBackground), - in: RoundedRectangle(cornerRadius: CardMetrics.cornerRadius) - ) - } - - private func inviteMenu(for invite: InviteEntity) -> some View { - Menu { - Button { Task { await decline(invite) } } label: { - Label("Decline", systemImage: "xmark") - } - Button(role: .destructive) { blockTarget = invite } label: { - Label("Block", systemImage: "hand.raised") - } - } label: { - Text("More") - .foregroundStyle(.primary) - } - .buttonStyle(.bordered) - .controlSize(.small) - .tint(.secondary) - .compositingGroup() - } - - @ViewBuilder - private func inviteRow(for invite: InviteEntity) -> some View { - let inviter = invite.resolvedInviterName ?? "A player" - let title = (invite.gameTitle?.isEmpty == false) ? invite.gameTitle! : "a puzzle" - HStack { - inviteThumbnail(for: invite) - VStack(alignment: .leading, spacing: 2) { - Text(title).font(.body.weight(.medium)) - Text("Invited by \(inviter)") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - if acceptingInviteID == invite.objectID { - ProgressView() - } else { - Button("Accept") { Task { await accept(invite) } } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } - inviteMenu(for: invite) - } - .swipeActions(edge: .trailing) { - Button("Decline") { Task { await decline(invite) } } - .tint(.gray) - Button("Block", role: .destructive) { blockTarget = invite } - } - } - - private func accept(_ invite: InviteEntity) async { - guard let acceptInvite, - let url = invite.shareURL, - let ping = invite.pingRecordName - else { return } - acceptingInviteID = invite.objectID - announcements.dismiss(id: Self.inviteErrorID) - defer { acceptingInviteID = nil } - do { - try await acceptInvite(url, ping) - } catch { - announcements.post(Announcement( - id: Self.inviteErrorID, - scope: .global, - severity: .error, - title: "Accepting Failed", - body: error.localizedDescription, - dismissal: .manual - )) - } - } - - /// Single-slot id for the invite-accept failure banner — a fresh - /// failure replaces the prior one rather than stacking. - private static let inviteErrorID = "invite-accept-error" - - /// Single-slot id for game-list destructive-action failures (decline, - /// resign, delete) — a fresh failure replaces the prior one. - private static let destructiveActionErrorID = "game-list-destructive-action-error" - - private func decline(_ invite: InviteEntity) async { - guard let declineInvite, let gameID = invite.gameID else { return } - do { - try await declineInvite(gameID) - } catch { - announcements.post(Announcement( - id: Self.destructiveActionErrorID, - scope: .global, - severity: .error, - title: "Declining Failed", - body: error.localizedDescription, - dismissal: .manual - )) - } - } - - @ViewBuilder - private func rowView(for game: GameSummary, usesRoomierType: Bool) -> some View { - GameRowView( - game: game, - shareController: shareController, - usesRoomierType: usesRoomierType, - onResume: { navigationPath.append(game.id) }, - onLeave: { leaveTarget = game }, - onResign: { resignTarget = game }, - onDelete: { deleteTarget = game } - ) - .background( - NavigationLink(value: game.id) { EmptyView() } - .opacity(0) - ) - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if !game.isOwned && game.isShared { - Button("Leave", role: .destructive) { - leaveTarget = game - } - } else { - Button("Delete", role: .destructive) { - deleteTarget = game - } - } - } - } - - private func leaveShare(game: GameSummary) async { - do { - try await shareController.leaveShare(gameID: game.id) - leaveTarget = nil - } catch { - leaveError = error - leaveTarget = nil - } - } - - private func usesRoomierType(for size: CGSize) -> Bool { - size.height >= 760 && dynamicTypeSize <= .medium - } -} - -// MARK: - Row - -private struct GameRowView: View { - let game: GameSummary - let shareController: ShareController - let usesRoomierType: Bool - var onResume: () -> Void = {} - var onLeave: () -> Void = {} - var onResign: () -> Void = {} - var onDelete: () -> Void = {} - @State private var isShowingShareSheet = false - - var body: some View { - let showsUnreadBadge = game.hasUnreadOtherMoves - - HStack(spacing: 12) { - GridThumbnailView( - width: game.gridWidth, - height: game.gridHeight, - cells: game.thumbnailCells - ) - .overlay(alignment: .topTrailing) { - if showsUnreadBadge { - Circle() - .fill(.red) - .frame(width: 14, height: 14) - .overlay( - Circle() - .stroke(.background, lineWidth: 2) - ) - .offset(x: 5, y: -5) - .accessibilityLabel("Unseen changes") - } - } - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Text(game.title) - .font((usesRoomierType ? Font.headline : .subheadline).weight(.semibold)) - .lineLimit(1) - .minimumScaleFactor(0.8) - .truncationMode(.tail) - if game.isShared { - Image(systemName: "person.2.fill") - .font(.caption) - .foregroundStyle(.secondary) - } - } - GameMetadataView( - puzzleDate: game.puzzleDate, - publisher: game.publisher, - usesRoomierType: usesRoomierType - ) - if let date = game.updatedAt { - LastUpdatedView(date: date, usesRoomierType: usesRoomierType) - } - } - Spacer() - GameOverflowMenu( - game: game, - onShare: { isShowingShareSheet = true }, - onResume: onResume, - onLeave: onLeave, - onResign: onResign, - onDelete: onDelete - ) - } - .padding(.vertical, 4) - .sheet(isPresented: $isShowingShareSheet) { - GameShareSheet( - gameID: game.id, - title: game.title, - shareController: shareController - ) - } - } -} - -// MARK: - Card (regular width) - -private enum CardMetrics { - static let height: CGFloat = 88 - static let cornerRadius: CGFloat = 12 -} - -/// Tappable card used in the iPad grid layout. The whole card is one -/// `Button` (so the pressed-state highlight covers the full card), and the -/// overflow `Menu` is layered as an `.overlay` rather than nested inside the -/// button — keeping them siblings means tapping the ellipsis opens the menu -/// instead of also firing the navigation action. -private struct GameCardView: View { - let game: GameSummary - let shareController: ShareController - let usesRoomierType: Bool - var onResume: () -> Void = {} - var onLeave: () -> Void = {} - var onResign: () -> Void = {} - var onDelete: () -> Void = {} - @State private var isShowingShareSheet = false - - var body: some View { - let showsUnreadBadge = game.hasUnreadOtherMoves - - Button(action: onResume) { - HStack(spacing: 12) { - GridThumbnailView( - width: game.gridWidth, - height: game.gridHeight, - cells: game.thumbnailCells - ) - .overlay(alignment: .topTrailing) { - if showsUnreadBadge { - Circle() - .fill(.red) - .frame(width: 14, height: 14) - .overlay( - Circle() - .stroke(.background, lineWidth: 2) - ) - .offset(x: 5, y: -5) - .accessibilityLabel("Unseen changes") - } - } - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Text(game.title) - .font((usesRoomierType ? Font.headline : .subheadline).weight(.semibold)) - .lineLimit(1) - .minimumScaleFactor(0.8) - .truncationMode(.tail) - if game.isShared { - Image(systemName: "person.2.fill") - .font(.caption) - .foregroundStyle(.secondary) - } - } - GameMetadataView( - puzzleDate: game.puzzleDate, - publisher: game.publisher, - usesRoomierType: usesRoomierType - ) - if let date = game.updatedAt { - LastUpdatedView(date: date, usesRoomierType: usesRoomierType) - } - } - Spacer(minLength: 0) - // Reserve room for the overflow menu, which is layered as an - // overlay so its taps don't fall through to this button. - Color.clear.frame(width: 32, height: 32) - } - .padding(12) - .frame(maxWidth: .infinity) - .frame(height: CardMetrics.height) - } - .buttonStyle(CardButtonStyle()) - .overlay(alignment: .trailing) { - GameOverflowMenu( - game: game, - onShare: { isShowingShareSheet = true }, - onResume: onResume, - onLeave: onLeave, - onResign: onResign, - onDelete: onDelete - ) - .padding(.trailing, 12) - } - .sheet(isPresented: $isShowingShareSheet) { - GameShareSheet( - gameID: game.id, - title: game.title, - shareController: shareController - ) - } - } -} - -private struct CardButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .background( - Color(.secondarySystemGroupedBackground), - in: RoundedRectangle(cornerRadius: CardMetrics.cornerRadius) - ) - .overlay { - if configuration.isPressed { - RoundedRectangle(cornerRadius: CardMetrics.cornerRadius) - .fill(Color.primary.opacity(0.06)) - } - } - .contentShape(RoundedRectangle(cornerRadius: CardMetrics.cornerRadius)) - } -} - -// MARK: - Shared overflow menu - -private struct GameOverflowMenu: View { - let game: GameSummary - var onShare: () -> Void - var onResume: () -> Void - var onLeave: () -> Void - var onResign: () -> Void - var onDelete: () -> Void - - var body: some View { - Menu { - Button { onShare() } label: { - Label("Share", systemImage: "square.and.arrow.up") - } - .disabled(!game.isOwned) - Button { onResume() } label: { - Label("Resume", systemImage: "square.and.pencil") - } - Section { - Button(role: .destructive) { onResign() } label: { - Label("Resign", systemImage: "flag") - } - if !game.isOwned && game.isShared { - Button(role: .destructive) { onLeave() } label: { - Label("Leave", systemImage: "rectangle.portrait.and.arrow.right") - } - } else { - Button(role: .destructive) { onDelete() } label: { - Label("Delete", systemImage: "trash") - } - } - } - } label: { - Image(systemName: "ellipsis") - .font(.body) - .frame(width: 32, height: 32) - .contentShape(Rectangle()) - } - .tint(.secondary) - .compositingGroup() - } -} - -private struct LastUpdatedView: View { - let date: Date - let usesRoomierType: Bool - - var body: some View { - TimelineView(.lastUpdated(from: date)) { context in - Text(text(now: context.date)) - .font(usesRoomierType ? .footnote : .caption) - .foregroundStyle(.secondary) - } - } - - private func text(now: Date) -> String { - let elapsed = max(0, now.timeIntervalSince(date)) - if elapsed < 60 { - let seconds = max(1, Int(elapsed.rounded(.down))) - return "Last updated \(seconds) \(seconds == 1 ? "second" : "seconds") ago" - } - if elapsed < 60 * 60 { - let minutes = Int((elapsed / 60).rounded(.down)) - return "Last updated \(minutes) \(minutes == 1 ? "minute" : "minutes") ago" - } - if elapsed <= 48 * 60 * 60 { - let hours = Int((elapsed / (60 * 60)).rounded(.down)) - return "Last updated \(hours) \(hours == 1 ? "hour" : "hours") ago" - } - return "Last updated on \(date.formatted(.dateTime.day().month(.abbreviated).year()))" - } -} - -private struct LastUpdatedSchedule: TimelineSchedule { - let anchor: Date - - func entries(from startDate: Date, mode: TimelineScheduleMode) -> AnyIterator<Date> { - var next = startDate - return AnyIterator { - let current = next - let elapsed = max(0, current.timeIntervalSince(anchor)) - let step: TimeInterval - if elapsed < 60 { - step = 1 - } else if elapsed < 60 * 60 { - step = 60 - } else if elapsed <= 48 * 60 * 60 { - step = 60 * 60 - } else { - return nil - } - next = current.addingTimeInterval(step) - return current - } - } -} - -extension TimelineSchedule where Self == LastUpdatedSchedule { - static func lastUpdated(from date: Date) -> LastUpdatedSchedule { - LastUpdatedSchedule(anchor: date) - } -} - -private struct GameMetadataView: View { - let puzzleDate: Date? - let publisher: String? - let usesRoomierType: Bool - - private var font: Font { - usesRoomierType ? .subheadline : .footnote - } - - var body: some View { - if let puzzleDate, let publisher { - ViewThatFits(in: .horizontal) { - HStack(spacing: 0) { - Text(puzzleDate, format: .dateTime.day().month(.abbreviated).year()) - Text(" • ") - Text(publisher) - } - .font(font) - .lineLimit(1) - - VStack(alignment: .leading, spacing: 2) { - puzzleDateView(puzzleDate) - publisherView(publisher) - } - } - } else { - if let puzzleDate { - puzzleDateView(puzzleDate) - } - if let publisher { - publisherView(publisher) - } - } - } - - private func puzzleDateView(_ puzzleDate: Date) -> some View { - Text(puzzleDate, format: .dateTime.day().month(.abbreviated).year()) - .font(font) - } - - private func publisherView(_ publisher: String) -> some View { - Text(publisher) - .font(font) - .lineLimit(1) - .truncationMode(.tail) - } -} diff --git a/Crossmate/Views/CellView.swift b/Crossmate/Views/Puzzle/CellView.swift diff --git a/Crossmate/Views/Puzzle/ClueBar.swift b/Crossmate/Views/Puzzle/ClueBar.swift @@ -0,0 +1,218 @@ +import SwiftUI + +private struct ClueKey: Hashable { + let direction: Puzzle.Direction + let number: Int +} + +private struct ReplayClueTarget { + let position: GridPosition + let direction: Puzzle.Direction? +} + +struct ClueBarSlot: View { + @Bindable var session: PlayerSession + let replayFrame: ReplayFrame? + + private var replayClueTarget: ReplayClueTarget? { + guard let cursor = replayFrame?.cursor else { return nil } + return ReplayClueTarget(position: cursor, direction: replayFrame?.cursorDirection) + } + + var body: some View { + ZStack(alignment: .bottom) { + ClueBarReservation() + + ClueBar(session: session, replayClueTarget: replayClueTarget) + } + } +} + +private struct ClueBarReservation: View { + var body: some View { + ClueBarContent( + label: "99 Across", + clueText: "Clue reservation", + reservesClueSpace: true + ) + .opacity(0) + .accessibilityHidden(true) + .allowsHitTesting(false) + } +} + +private struct ClueBarContent: View { + let label: String + let clueText: String + var reservesClueSpace = false + var currentKey: ClueKey? + var slideEdge: Edge = .trailing + var onPrevious: (() -> Void)? + var onNext: (() -> Void)? + var onClueTap: (() -> Void)? + var onLabelTap: (() -> Void)? + + var body: some View { + HStack(alignment: .clueCenter, spacing: 8) { + ClueBarIcon(systemName: "chevron.left", action: onPrevious) + + VStack(alignment: .leading, spacing: 4) { + Text(label) + .font(.caption) + .textCase(.uppercase) + .foregroundStyle(.secondary) + .contentShape(Rectangle()) + .highPriorityGesture( + TapGesture() + .onEnded { + onLabelTap?() + } + ) + ZStack(alignment: .leading) { + clueTextView + } + .alignmentGuide(.clueCenter) { d in d[VerticalAlignment.center] } + .frame(maxWidth: .infinity, alignment: .leading) + .clipped() + } + .contentShape(Rectangle()) + .onTapGesture { + onClueTap?() + } + + ClueBarIcon(systemName: "chevron.right", action: onNext) + } + .padding(.horizontal, 8) + .padding(.top, 12) + .padding(.bottom, 6) + } + + @ViewBuilder + private var clueTextView: some View { + baseClueText + .id(currentKey) + .transition(.asymmetric( + insertion: .move(edge: slideEdge), + removal: .move(edge: slideEdge == .trailing ? .leading : .trailing) + )) + } + + private var baseClueText: some View { + Text(clueText) + .font(.headline) + .lineLimit(2, reservesSpace: reservesClueSpace) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct ClueBarIcon: View { + let systemName: String + var action: (() -> Void)? + + var body: some View { + if let action { + Button(action: action) { + icon + } + .buttonStyle(.plain) + } else { + icon + } + } + + private var icon: some View { + Image(systemName: systemName) + .font(.title3.weight(.semibold)) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } +} + +private struct ClueBar: View { + @Bindable var session: PlayerSession + let replayClueTarget: ReplayClueTarget? + @Environment(PlayerPreferences.self) private var preferences + @Environment(\.colorScheme) private var colorScheme + @State private var slideEdge: Edge = .trailing + @State private var isShowingClueList = false + + private var backgroundColor: Color { + preferences.color.clueBarFill(dark: colorScheme == .dark) + } + + var body: some View { + let display = replayClueDisplay ?? liveClueDisplay + let isShowingReplayClue = replayClueDisplay != nil + + ClueBarContent( + label: label(for: display.clue, direction: display.direction), + clueText: display.clue?.text ?? "—", + currentKey: display.currentKey, + slideEdge: slideEdge, + onPrevious: isShowingReplayClue ? nil : { + slideEdge = .leading + session.goToPreviousClue() + }, + onNext: isShowingReplayClue ? nil : { + slideEdge = .trailing + session.goToNextClue() + }, + onClueTap: isShowingReplayClue ? nil : { + isShowingClueList = true + }, + onLabelTap: isShowingReplayClue ? nil : { + session.toggleDirection() + } + ) + .background(backgroundColor) + .animation( + isShowingReplayClue ? nil : .smooth(duration: 0.22), + value: display.currentKey + ) + .sheet(isPresented: $isShowingClueList) { + ClueList(session: session) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + } + + private var liveClueDisplay: ClueDisplay { + let clue = session.currentClue() + return ClueDisplay(clue: clue, direction: session.direction) + } + + private var replayClueDisplay: ClueDisplay? { + guard let replayClueTarget else { return nil } + let position = replayClueTarget.position + guard let direction = replayClueTarget.direction else { return nil } + return ClueDisplay( + clue: session.puzzle.clue(atRow: position.row, col: position.col, direction: direction), + direction: direction + ) + } + + private struct ClueDisplay { + let clue: Puzzle.Clue? + let direction: Puzzle.Direction + + var currentKey: ClueKey? { + clue.map { ClueKey(direction: direction, number: $0.number) } + } + } + + private func label(for clue: Puzzle.Clue?, direction: Puzzle.Direction) -> String { + let direction = direction == .across ? "Across" : "Down" + if let clue { + return "\(clue.number) \(direction)" + } + return direction + } +} + +private extension VerticalAlignment { + enum ClueCenterID: AlignmentID { + static func defaultValue(in d: ViewDimensions) -> CGFloat { d[VerticalAlignment.center] } + } + static let clueCenter = VerticalAlignment(ClueCenterID.self) +} diff --git a/Crossmate/Views/ClueList.swift b/Crossmate/Views/Puzzle/ClueList.swift diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/Puzzle/GridView.swift diff --git a/Crossmate/Views/HardwareKeyboardInputView.swift b/Crossmate/Views/Puzzle/HardwareKeyboardInputView.swift diff --git a/Crossmate/Views/JoiningPuzzleView.swift b/Crossmate/Views/Puzzle/JoiningPuzzleView.swift diff --git a/Crossmate/Views/KeyboardView.swift b/Crossmate/Views/Puzzle/KeyboardView.swift diff --git a/Crossmate/Views/Puzzle/PuzzleHeader.swift b/Crossmate/Views/Puzzle/PuzzleHeader.swift @@ -0,0 +1,218 @@ +import SwiftUI + +/// Swipeable header that sits above the grid. Page 1 is the title, the +/// last page is the credits, and on iPhone a scoreboard page sits between +/// them (iPad shows the scoreboard permanently in the side panel, so it is +/// omitted here). A fixed height is required because `.page` style fills +/// its container rather than sizing to content. +struct PuzzleHeader: View { + @Bindable var session: PlayerSession + let roster: PlayerRoster + let title: String + let subtitle: String? + let showsScoreboard: Bool + let gameID: UUID + let isEngagementLive: Bool + /// The shared open "arm" beat, owned by `PuzzleView` so the banner and the + /// grid's "changed while you were away" borders reveal together. Until it + /// flips (a moment after open), the title is the only thing on screen; + /// then banner posts — including a session summary that arrived during the + /// hold — animate in. + let isArmed: Bool + @Environment(AnnouncementCenter.self) private var announcements + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @State private var selection: Page = .title + + private enum Page: Hashable { + case title + case scoreboard + case credits + } + + /// Reconstructed as "© <year> <publisher>" from the puzzle's date and + /// publisher, falling back to whatever pieces exist, and finally to the + /// raw copyright string parsed from the source. + private var copyrightLine: String? { + let year = session.puzzle.date.map { + Calendar.current.component(.year, from: $0) + } + switch (year, session.puzzle.publisher) { + case let (year?, publisher?): + return "© \(year) \(publisher)" + case let (year?, nil): + return "© \(year)" + case let (nil, publisher?): + return "© \(publisher)" + case (nil, nil): + return session.puzzle.copyright + } + } + + private var hasCredits: Bool { + session.puzzle.author != nil || copyrightLine != nil + } + + private var pages: [Page] { + var result: [Page] = [.title] + if showsScoreboard { result.append(.scoreboard) } + if hasCredits { result.append(.credits) } + return result + } + + /// Above the default text size the clue bar below the grid grows to fit + /// the (must-read) clue, squeezing the grid. The title/scoreboard/credits + /// shown here are the least important text on screen, so the header yields + /// its own height as type scales up — shedding a few points per step down + /// to a legible-enough floor — and hands that space back to the grid. The + /// text inside just truncates within the smaller box. At or below the + /// default size the comfortable full height is preserved. + private var headerHeight: CGFloat { + let sizes = DynamicTypeSize.allCases + guard let current = sizes.firstIndex(of: dynamicTypeSize), + let baseline = sizes.firstIndex(of: .large) + else { return 80 } + let stepsAboveDefault = max(0, current - baseline) + return max(48, 80 - CGFloat(stepsAboveDefault) * 6) + } + + var body: some View { + let visibleAnnouncement = isArmed + ? announcements.current(forGame: gameID) + : nil + Group { + // Title/scoreboard/credits is the baseline — it renders + // immediately on open and stays put. After the open beat we + // start reacting to announcements: the banner slides down + // over the title and slides back out on dismissal. Both + // branches occupy the same fixed-height frame so the grid + // below doesn't jump. + if let announcement = visibleAnnouncement { + AnnouncementBanner( + announcement: announcement, + fillsAvailableHeight: true + ) { + announcements.dismiss(id: announcement.id) + } + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } else { + headerPages + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .frame(height: headerHeight) + .padding(.bottom, 14) + .animation(.easeInOut(duration: 0.3), value: visibleAnnouncement) + .animation(.easeInOut(duration: 0.2), value: isEngagementLive) + } + + private var headerPages: some View { + VStack(spacing: 10) { + TabView(selection: $selection) { + ForEach(pages, id: \.self) { page in + pageContent(page) + .tag(page) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + + if pages.count > 1 { + HStack(spacing: 6) { + ForEach(pages, id: \.self) { page in + Circle() + .fill(page == selection ? Color.secondary : Color.secondary.opacity(0.3)) + .frame(width: 6, height: 6) + } + } + .animation(.easeInOut(duration: 0.2), value: selection) + } + } + } + + @ViewBuilder + private func pageContent(_ page: Page) -> some View { + switch page { + case .title: + PuzzleTitle(title: title, subtitle: subtitle, isEngagementLive: isEngagementLive) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + case .scoreboard: + PuzzleScoreboard(session: session, roster: roster, layout: .horizontal) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + case .credits: + PuzzleCredits(author: session.puzzle.author, copyright: copyrightLine) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + } +} + +private struct PuzzleTitle: View { + let title: String + let subtitle: String? + let isEngagementLive: Bool + @State private var showsEngagementIcon = false + + var body: some View { + VStack(spacing: 2) { + Text(title) + .font(.headline) + .lineLimit(2) + .overlay(alignment: .trailing) { + engagementIcon + .offset(x: 28) + .opacity(showsEngagementIcon ? 1 : 0) + .accessibilityLabel("Engagement live") + .accessibilityHidden(!showsEngagementIcon) + } + if let subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.horizontal) + .animation(.easeInOut(duration: 0.2), value: showsEngagementIcon) + .onAppear { + showsEngagementIcon = isEngagementLive + } + .onChange(of: isEngagementLive) { _, isLive in + withAnimation(.easeInOut(duration: 0.2)) { + showsEngagementIcon = isLive + } + } + } + + private var engagementIcon: some View { + Image(systemName: "bolt.circle") + .font(.headline) + .foregroundStyle(.green) + .symbolRenderingMode(.monochrome) + } +} + +private struct PuzzleCredits: View { + let author: String? + let copyright: String? + + var body: some View { + VStack(spacing: 2) { + if let author, !author.isEmpty { + Text("By \(author)") + .font(.subheadline) + .lineLimit(2) + } + if let copyright { + Text(copyright) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.horizontal) + } +} diff --git a/Crossmate/Views/Puzzle/PuzzleModifiers.swift b/Crossmate/Views/Puzzle/PuzzleModifiers.swift @@ -0,0 +1,370 @@ +import SwiftUI + +struct PuzzleToolbarModifier: ViewModifier { + let session: PlayerSession + let roster: PlayerRoster + let shareController: ShareController? + let isSolved: Bool + let canResign: Bool + let canDelete: Bool + @Binding var isRenaming: Bool + @Binding var renameDraft: String + @Binding var isConfirmingResign: Bool + @Binding var isConfirmingDelete: Bool + @Binding var isConfirmingLeave: Bool + @Binding var isConfirmingReveal: Bool + @Binding var pendingRevealScope: RevealScope + @Binding var isShowingShareSheet: Bool + @Environment(PlayerPreferences.self) private var preferences + @AppStorage("debugMode") private var debugMode = false + + func body(content: Content) -> some View { + content.toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + pencilButton + entryMenu + hintsMenu + playersMenu + } + } + } + + private func swatchImage(for color: PlayerColor) -> Image { + let tint = UIColor(color.tint) + let base = UIImage(systemName: "circle.fill") ?? UIImage() + return Image(uiImage: base.withTintColor(tint, renderingMode: .alwaysOriginal)) + } + + private var pencilButton: some View { + Button { + session.togglePencil() + } label: { + Image(systemName: "pencil") + .foregroundStyle(pencilButtonForeground) + .padding(6) + .glassEffect( + !isSolved && session.isPencilMode + ? .regular.tint(preferences.color.tint) + : .identity, + in: Circle() + ) + } + .accessibilityLabel("Pencil") + .disabled(isSolved) + } + + private var pencilButtonForeground: Color { + if isSolved { + return .secondary + } + return session.isPencilMode ? .white : .primary + } + + private var entryMenu: some View { + Menu { + Section { + Button("Undo Move") { session.undo() } + .disabled(!session.canUndo) + Button("Redo Move") { session.redo() } + .disabled(!session.canRedo) + } + + Section { + Button("Enter Rebus") { session.startRebus() } + Button("Toggle Direction") { session.toggleDirection() } + } + + if debugMode { + Section { + NavigationLink { + DiagnosticsView() + } label: { + Text("Diagnostics Log") + } + } + } + + Section { + Button("Clear Word") { session.clearCurrentWord() } + Button("Clear Puzzle", role: .destructive) { session.clearPuzzle() } + } + } label: { + Label("Entry", systemImage: "squareshape.split.2x2") + } + .disabled(isSolved) + } + + private var hintsMenu: some View { + Menu { + Section { + Button("Check Square") { session.checkSquare() } + Button("Check Word") { session.checkCurrentWord() } + Button("Check Puzzle") { session.checkPuzzle() } + } + Section { + Button("Reveal Square") { confirmReveal(.square) } + Button("Reveal Word") { confirmReveal(.word) } + Button("Reveal Puzzle") { confirmReveal(.puzzle) } + } + } label: { + Label("Hints", systemImage: "lightbulb") + } + .disabled(isSolved) + } + + private func confirmReveal(_ scope: RevealScope) { + pendingRevealScope = scope + isConfirmingReveal = true + } + + private var playersMenu: some View { + Menu { + playerRosterSection + playerPreferencesSection + shareSection + puzzleDestructiveSection + } label: { + Label("Players", systemImage: "person.2") + } + .disabled(isSolved) + } + + @ViewBuilder + private var playerRosterSection: some View { + Section { + if !roster.entries.isEmpty { + ForEach(roster.entries) { entry in + Button {} label: { + Label { + Text(entry.isLocal ? "\(entry.name) (you)" : entry.name) + } icon: { + swatchImage(for: entry.color) + } + } + .disabled(true) + } + } else { + Button {} label: { + Label { + Text(preferences.name) + } icon: { + swatchImage(for: preferences.color) + } + } + .disabled(true) + } + } + } + + private var playerPreferencesSection: some View { + Section { + Menu("Change Colour") { + ForEach(PlayerColor.palette) { color in + Button { + preferences.color = color + // Friend colours are derived with the local user's + // colour reserved, so refreshing re-derives and bumps + // any friend that now collides with the new choice. + Task { await roster.refresh() } + } label: { + Label { + Text(color.id == preferences.colorID ? "\(color.name) ✓" : color.name) + } icon: { + swatchImage(for: color) + } + } + } + } + + Button("Change Name") { + renameDraft = preferences.name + isRenaming = true + } + } + } + + @ViewBuilder + private var shareSection: some View { + if shareController != nil { + Section { + Button { + isShowingShareSheet = true + } label: { + Text("Share Game") + } + .disabled(!session.mutator.isOwned) + } + } + } + + private var puzzleDestructiveSection: some View { + Section { + Button("Resign Game", role: .destructive) { + isConfirmingResign = true + } + .disabled(isSolved || !canResign) + + if session.mutator.isShared && !session.mutator.isOwned { + Button("Leave Game", role: .destructive) { + isConfirmingLeave = true + } + .disabled(shareController == nil) + } else { + Button("Delete Game", role: .destructive) { + isConfirmingDelete = true + } + .disabled(!canDelete) + } + } + } +} + +struct PuzzleLifecycleModifier: ViewModifier { + let session: PlayerSession + let roster: PlayerRoster + @Binding var hasSolved: Bool + let onCompletionEvent: (PlayerSession.CompletionEvent) -> Void + let onSolvedOnAppear: () -> Void + + func body(content: Content) -> some View { + content + .task { + await roster.refresh() + } + .onAppear { + if session.game.completionState == .solved { + hasSolved = true + onSolvedOnAppear() + } + } + .onChange(of: session.completionEvent) { _, newValue in + guard let newValue else { return } + onCompletionEvent(newValue) + } + } +} + +struct PuzzlePresentationModifier: ViewModifier { + let session: PlayerSession + let shareController: ShareController? + @Binding var isRenaming: Bool + @Binding var renameDraft: String + @Binding var showErrorsAlert: Bool + @Binding var isConfirmingResign: Bool + @Binding var isConfirmingDelete: Bool + @Binding var isConfirmingLeave: Bool + @Binding var isConfirmingReveal: Bool + @Binding var pendingRevealScope: RevealScope + @Binding var leaveError: String? + @Binding var destructiveActionError: String? + @Binding var isShowingShareSheet: Bool + let performResign: () -> Void + let performDelete: () -> Void + let leaveSharedGame: () async -> Void + @Environment(PlayerPreferences.self) private var preferences + + func body(content: Content) -> some View { + content + .alert("Not Quite Right", isPresented: $showErrorsAlert) { + Button("OK", role: .cancel) {} + } message: { + Text("One or more squares are incorrect.") + } + .alert("Resign Puzzle?", isPresented: $isConfirmingResign) { + Button("Resign", role: .destructive) { + performResign() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will reveal the puzzle and mark it complete.") + } + .alert("Delete Puzzle?", isPresented: $isConfirmingDelete) { + Button("Delete", role: .destructive) { + performDelete() + } + Button("Cancel", role: .cancel) {} + } message: { + deleteConfirmationMessage + } + .alert("Leave Puzzle?", isPresented: $isConfirmingLeave) { + Button("Leave", role: .destructive) { + Task { await leaveSharedGame() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("You will lose access to \"\(session.puzzle.title)\".") + } + .alert(pendingRevealScope.title, isPresented: $isConfirmingReveal) { + Button("Reveal", role: .destructive) { + performReveal(pendingRevealScope) + } + Button("Cancel", role: .cancel) {} + } message: { + Text(pendingRevealScope.message) + } + .alert( + "Couldn't Leave", + isPresented: .init( + get: { leaveError != nil }, + set: { if !$0 { leaveError = nil } } + ), + presenting: leaveError + ) { _ in + Button("OK", role: .cancel) {} + } message: { message in + Text(message) + } + .alert( + "Couldn't Update Puzzle", + isPresented: .init( + get: { destructiveActionError != nil }, + set: { if !$0 { destructiveActionError = nil } } + ), + presenting: destructiveActionError + ) { _ in + Button("OK", role: .cancel) {} + } message: { message in + Text(message) + } + .alert("Change Name", isPresented: $isRenaming) { + TextField("Name", text: $renameDraft) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Button("Cancel", role: .cancel) {} + Button("Save") { + let trimmed = renameDraft.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + preferences.name = trimmed + } + } + .keyboardShortcut(.defaultAction) + } message: { + Text("Enter the name other players will see.") + } + .sheet(isPresented: $isShowingShareSheet) { + if let shareController { + GameShareSheet( + gameID: session.mutator.gameID, + title: session.puzzle.title, + shareController: shareController + ) + } + } + } + + private func performReveal(_ scope: RevealScope) { + switch scope { + case .square: session.revealSquare() + case .word: session.revealCurrentWord() + case .puzzle: session.revealPuzzle() + } + } + + private var deleteConfirmationMessage: Text { + if session.mutator.isOwned && session.mutator.isShared { + Text("This will permanently delete \"\(session.puzzle.title)\" from iCloud for everyone.") + } else { + Text("This will permanently delete \"\(session.puzzle.title)\" and all progress.") + } + } +} diff --git a/Crossmate/Views/Puzzle/PuzzleScoreboard.swift b/Crossmate/Views/Puzzle/PuzzleScoreboard.swift @@ -0,0 +1,259 @@ +import SwiftUI + +struct PuzzleScoreboard: View { + @Bindable var session: PlayerSession + let roster: PlayerRoster + var layout: Layout = .vertical + @Environment(PlayerPreferences.self) private var preferences + @ScaledMetric(relativeTo: .subheadline) private var horizontalHeaderHeight: CGFloat = 56 + + enum Layout { + /// Side-panel style: stacked rows under a "Players" heading. + case vertical + /// Paged-header style: a horizontally scrollable strip of player + /// chips, sized to scroll past two players when more arrive. + case horizontal + } + + private struct Score: Identifiable { + let authorID: String? + let name: String + let color: PlayerColor? + let filledCount: Int + + var id: String { authorID ?? "unattributed" } + } + + private var fillableCellCount: Int { + session.puzzle.cells.reduce(0) { count, row in + count + row.filter { !$0.isBlock }.count + } + } + + private var filledCellCount: Int { + var count = 0 + for r in 0..<session.puzzle.height { + for c in 0..<session.puzzle.width { + guard !session.puzzle.cells[r][c].isBlock else { continue } + if !session.game.squares[r][c].entry.isEmpty { + count += 1 + } + } + } + return count + } + + private var revealedSquareCount: Int { + var count = 0 + for r in 0..<session.puzzle.height { + for c in 0..<session.puzzle.width { + guard !session.puzzle.cells[r][c].isBlock else { continue } + if session.game.squares[r][c].mark.isRevealed { + count += 1 + } + } + } + return count + } + + private var remainingCount: Int { + max(0, fillableCellCount - filledCellCount) + } + + private var remainingPhrase: String { + switch remainingCount { + case 0: + return "no squares to go" + case 1: + return "1 square to go" + default: + return "\(remainingCount) squares to go" + } + } + + private var revealedPhrase: String { + switch revealedSquareCount { + case 0: + return "No squares revealed" + case 1: + return "1 square revealed" + default: + return "\(revealedSquareCount) squares revealed" + } + } + + private var progressText: String { + if revealedSquareCount > 0 { + return "\(revealedPhrase), \(remainingPhrase)" + } + switch remainingCount { + case 0: + return "No squares to go" + case 1: + return "1 square to go" + default: + return "\(remainingCount) squares to go" + } + } + + private var scores: [Score] { + var counts: [String?: Int] = [:] + for r in 0..<session.puzzle.height { + for c in 0..<session.puzzle.width { + guard !session.puzzle.cells[r][c].isBlock else { continue } + let square = session.game.squares[r][c] + guard !square.entry.isEmpty, !square.mark.isRevealed else { continue } + counts[normalizedAuthorID(square.letterAuthorID), default: 0] += 1 + } + } + + let entries = roster.entries + let usesLocalFallback = entries.isEmpty + let entryByAuthorID = Dictionary(uniqueKeysWithValues: entries.map { ($0.authorID, $0) }) + let rosterAuthorIDs = Set(entries.map(\.authorID)) + + let rosterScores: [Score] + if usesLocalFallback { + rosterScores = [ + Score( + authorID: nil, + name: preferences.name, + color: preferences.color, + filledCount: counts[nil] ?? 0 + ) + ] + } else { + rosterScores = entries.map { entry in + Score( + authorID: entry.authorID, + name: entry.name, + color: entry.color, + filledCount: counts[entry.authorID] ?? 0 + ) + } + } + + let extraScores = counts.compactMap { authorID, count -> Score? in + if let authorID, rosterAuthorIDs.contains(authorID) { + return nil + } + if authorID == nil && usesLocalFallback { + return nil + } + if let authorID, let entry = entryByAuthorID[authorID] { + return Score(authorID: authorID, name: entry.name, color: entry.color, filledCount: count) + } + if authorID == nil { + // A `nil` author key only arises with remote players present + // (see `normalizedAuthorID`): an authorless square, e.g. a cell + // sealed to the solution at completion before its author's + // letter arrived. It belongs to no player, so drop it rather + // than tallying an "Unattributed" entry. + return nil + } + return Score(authorID: authorID, name: "Player", color: nil, filledCount: count) + } + + return (rosterScores + extraScores) + .sorted { + if $0.filledCount != $1.filledCount { return $0.filledCount > $1.filledCount } + return $0.name < $1.name + } + } + + private func normalizedAuthorID(_ authorID: String?) -> String? { + guard let authorID else { + return roster.entries.contains(where: { !$0.isLocal }) ? nil : roster.localAuthorID + } + return authorID + } + + @ViewBuilder + var body: some View { + switch layout { + case .vertical: + verticalBody + case .horizontal: + horizontalBody + } + } + + private var verticalBody: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Players") + .font(.headline) + + VStack(alignment: .leading, spacing: 6) { + ForEach(scores) { score in + scoreRow(score) + } + + Text(progressText) + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.top, 10) + .frame(maxWidth: .infinity, alignment: .center) + } + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var chipFlow: some View { + FlowLayout(spacing: 18, lineSpacing: 8) { + ForEach(scores) { score in + scoreChip(score) + } + } + .padding(.horizontal, 18) + .padding(.vertical, 4) + } + + private var horizontalBody: some View { + // A titled "Players" section mirroring the iPad side panel + // (verticalBody). It sizes to its content and sits top-anchored + // in a ScrollView, so it reads as a deliberate header section + // rather than a stray chip, and scrolls when there are enough + // players to overflow the band — no centring tricks required. + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 6) { + Text("Players") + .font(.subheadline.weight(.semibold)) + chipFlow + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + .frame(height: horizontalHeaderHeight) + } + + private func scoreChip(_ score: Score) -> some View { + HStack(spacing: 6) { + Circle() + .fill(score.color?.tint ?? Color.secondary) + .frame(width: 8, height: 8) + Text(score.name) + .font(.subheadline) + .lineLimit(1) + Text("\(score.filledCount)") + .font(.subheadline.monospacedDigit().weight(.semibold)) + } + .accessibilityElement(children: .combine) + } + + private func scoreRow(_ score: Score) -> some View { + HStack(spacing: 8) { + Circle() + .fill(score.color?.tint ?? Color.secondary) + .frame(width: 8, height: 8) + Text(score.name) + .font(.subheadline) + .lineLimit(1) + Spacer(minLength: 8) + Text("\(score.filledCount)") + .font(.subheadline.monospacedDigit().weight(.semibold)) + } + .accessibilityElement(children: .combine) + } +} diff --git a/Crossmate/Views/Puzzle/PuzzleView.swift b/Crossmate/Views/Puzzle/PuzzleView.swift @@ -0,0 +1,581 @@ +import SwiftUI + +enum RevealScope { + case square + case word + case puzzle + + var title: String { + switch self { + case .square: "Reveal Square?" + case .word: "Reveal Word?" + case .puzzle: "Reveal Puzzle?" + } + } + + var message: String { + switch self { + case .square: "This will reveal the current square." + case .word: "This will reveal the current word." + case .puzzle: "This will reveal the entire puzzle and mark it complete." + } + } +} + +struct PuzzleView: View { + @Bindable var session: PlayerSession + var shareController: ShareController? = nil + let roster: PlayerRoster + var onComplete: ((_ notifyPeers: Bool) -> Void)? = nil + var onResign: (() throws -> Void)? = nil + var onDelete: (() throws -> Void)? = nil + /// Loads the finished game's merged journal for the finish-banner replay + /// scrubber. Defaults to `.unavailable` so previews/tests need not wire it. + var loadReplay: () async -> JournalReplayResult = { .unavailable } + /// Cells a peer filled or cleared since this player last viewed the puzzle, + /// mapped to the writing author. Read once on the open arm beat. Defaults to + /// empty so previews/tests need not wire it. + var loadRecentChanges: () -> [GridPosition: String] = { [:] } + /// Stamps this game's last-viewed timestamp (device-local). Called when the + /// away-change borders are acknowledged. Defaults to a no-op. + var markPuzzleViewed: () -> Void = {} + @Environment(InputMonitor.self) private var inputMonitor + @Environment(PlayerPreferences.self) private var preferences + @Environment(AnnouncementCenter.self) private var announcements + @Environment(\.dismiss) private var dismiss + @State private var isRenaming = false + @State private var renameDraft = "" + @State private var showErrorsAlert = false + @State private var isConfirmingResign = false + @State private var isConfirmingDelete = false + @State private var isConfirmingLeave = false + @State private var isConfirmingReveal = false + @State private var pendingRevealScope: RevealScope = .square + @State private var leaveError: String? + @State private var destructiveActionError: String? + @State private var isShowingShareSheet = false + @State private var hasSolved = false + @State private var replay = ReplayControls() + @State private var padLayout: PadLayout? + /// The shared open "arm" beat: flips a moment after open so the banner and + /// the "changed while you were away" borders reveal together. + @State private var isArmed = false + @Environment(\.engagementStatus) private var engagementStatus + + private enum PadLayout { + case landscape + case portrait + } + + private func swatchImage(for color: PlayerColor) -> Image { + let tint = UIColor(color.tint) + let base = UIImage(systemName: "circle.fill") ?? UIImage() + return Image(uiImage: base.withTintColor(tint, renderingMode: .alwaysOriginal)) + } + + private struct TitleParts { + let title: String + let subtitle: String? + } + + private var titleParts: TitleParts { + let title = session.puzzle.title + let formattedDate = session.puzzle.date?.formatted(date: .long, time: .omitted) + let subtitle: String? + if let publisher = session.puzzle.publisher, let formattedDate { + subtitle = "\(publisher) · \(formattedDate)" + } else if let publisher = session.puzzle.publisher { + subtitle = publisher + } else { + subtitle = formattedDate + } + return TitleParts(title: title, subtitle: subtitle) + } + + // Latched completion counts as solved for the read-only presentation + // (hides the keyboard, shows the finish panel, disables the controls) even + // when the locally merged grid drifted and no longer reads `.solved`. + private var isSolved: Bool { hasSolved || session.mutator.isCompleted } + + /// Whether a sticky, input-blocking announcement (currently only + /// access revocation) is showing for this game. Greys out the custom + /// keyboard and makes the hardware-key handler a no-op. + private var isInputBlocked: Bool { + announcements.isInputBlocked(forGame: session.mutator.gameID) + } + + var body: some View { + Group { + switch padLayout { + case .landscape: + landscapePadLayout + case .portrait: + portraitPadLayout + case .none: + phoneLayout + } + } + .background(Color(.systemBackground)) + .background { + HardwareKeyboardInputView(onPress: handleHardwareKeyboardEvent) + .frame(width: 0, height: 0) + .allowsHitTesting(false) + } + .ignoresSafeArea(.keyboard) + .modifier(PuzzleToolbarModifier( + session: session, + roster: roster, + shareController: shareController, + isSolved: isSolved, + canResign: onResign != nil, + canDelete: onDelete != nil, + isRenaming: $isRenaming, + renameDraft: $renameDraft, + isConfirmingResign: $isConfirmingResign, + isConfirmingDelete: $isConfirmingDelete, + isConfirmingLeave: $isConfirmingLeave, + isConfirmingReveal: $isConfirmingReveal, + pendingRevealScope: $pendingRevealScope, + isShowingShareSheet: $isShowingShareSheet + )) + .modifier(PuzzleLifecycleModifier( + session: session, + roster: roster, + hasSolved: $hasSolved, + onCompletionEvent: handleCompletionEvent, + onSolvedOnAppear: { + onComplete?(false) + } + )) + .modifier(PuzzlePresentationModifier( + session: session, + shareController: shareController, + isRenaming: $isRenaming, + renameDraft: $renameDraft, + showErrorsAlert: $showErrorsAlert, + isConfirmingResign: $isConfirmingResign, + isConfirmingDelete: $isConfirmingDelete, + isConfirmingLeave: $isConfirmingLeave, + isConfirmingReveal: $isConfirmingReveal, + pendingRevealScope: $pendingRevealScope, + leaveError: $leaveError, + destructiveActionError: $destructiveActionError, + isShowingShareSheet: $isShowingShareSheet, + performResign: performResign, + performDelete: performDelete, + leaveSharedGame: leaveSharedGame + )) + .onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { newSize in + updateLayoutTrait(for: newSize) + } + .onAppear { + session.onRecentChangesAcknowledged = markPuzzleViewed + } + .task(id: session.mutator.gameID) { + // The shared open beat. A short hold lets the puzzle settle and the + // on-open sync land; then we arm the banner and capture — once — + // which cells a peer changed while we were away, so both reveal + // together. Moves that arrive after this are live activity (peer + // cursor tints), not part of the away-summary. + isArmed = false + try? await Task.sleep(for: .milliseconds(750)) + isArmed = true + if session.mutator.isShared { + session.recentChanges = loadRecentChanges() + } + } + } + + private var phoneLayout: some View { + VStack(spacing: 0) { + puzzleArea + controlsArea(showClueBar: true) + } + } + + private var landscapePadLayout: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + VStack(spacing: 0) { + if !isSolved { + PuzzleScoreboard(session: session, roster: roster) + + Divider() + } + + ClueList(session: session, presentation: .sidebar, replayFrame: replay.frame) + } + .frame(minWidth: 300, idealWidth: 360, maxWidth: 420) + .background(Color(.secondarySystemBackground)) + + Divider() + .ignoresSafeArea(edges: .top) + + puzzleArea + .padding(.bottom, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + controlsArea(showClueBar: false) + } + } + + private var portraitPadLayout: some View { + VStack(spacing: 0) { + WeightedVStack(weights: [3, 1]) { + puzzleArea + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.bottom, 12) + + VStack(spacing: 0) { + Divider() + + HStack(alignment: .top, spacing: 0) { + if !isSolved { + PuzzleScoreboard(session: session, roster: roster) + .frame(minWidth: 240, idealWidth: 280, maxWidth: 320) + + Divider() + } + + ClueList(session: session, presentation: .sidebar, replayFrame: replay.frame) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(Color(.secondarySystemBackground)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + controlsArea(showClueBar: false) + } + } + + private func updateLayoutTrait(for size: CGSize) { + guard UIDevice.current.userInterfaceIdiom == .pad, size != .zero else { + padLayout = nil + return + } + padLayout = size.width > size.height ? .landscape : .portrait + } + + private func performResign() { + do { + try onResign?() + dismiss() + } catch { + destructiveActionError = String(describing: error) + } + } + + private func performDelete() { + do { + try onDelete?() + dismiss() + } catch { + destructiveActionError = String(describing: error) + } + } + + private func handleCompletionEvent(_ event: PlayerSession.CompletionEvent) { + switch (event.origin, event.state) { + case (_, .incomplete): + break + case (.observed, .filledWithErrors): + // A collaborator's wrong entry must not interrupt the local solver. + break + case (.local, .filledWithErrors): + showErrorsAlert = true + case (.local, .solved): + guard !hasSolved else { return } + hasSolved = true + if session.isPencilMode { + session.togglePencil() + } + Task { @MainActor in + onComplete?(true) + } + case (.observed, .solved): + guard !hasSolved else { return } + hasSolved = true + onComplete?(false) + } + } + + private var puzzleArea: some View { + ZStack { + VStack(spacing: 4) { + PuzzleHeader( + session: session, + roster: roster, + title: titleParts.title, + subtitle: titleParts.subtitle, + showsScoreboard: padLayout == nil, + gameID: session.mutator.gameID, + isEngagementLive: engagementStatus?.isLive(gameID: session.mutator.gameID) == true, + isArmed: isArmed + ) + GridView( + session: session, + roster: roster, + showsSharedAnnotations: session.mutator.isShared, + showsPeerCursors: !isSolved, + replayFrame: replay.frame + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.top, -8) + + if session.isRebusActive { + Color.black.opacity(0.35) + .ignoresSafeArea(edges: .top) + .contentShape(Rectangle()) + .onTapGesture { + session.commitRebus() + } + RebusModal(text: session.rebusBuffer) + .padding(.horizontal) + .contentShape(Rectangle()) + .onTapGesture { /* swallow */ } + } + } + } + + private func controlsArea(showClueBar: Bool) -> some View { + VStack(spacing: 0) { + if showClueBar { + ClueBarSlot(session: session, replayFrame: replay.frame) + } + controlsPanel + .frame(height: controlsPanelHeight) + } + } + + private var controlsPanel: some View { + ZStack(alignment: .top) { + if isSolved { + ControlsView(height: controlsPanelHeight) { + SuccessPanel( + session: session, + roster: roster, + replay: replay, + loadReplay: loadReplay + ) + } + .transition(.move(edge: .bottom)) + } else if showsCustomKeyboard { + ControlsView(height: controlsPanelHeight) { + KeyboardView(session: session, showsNavigationKeys: padLayout != nil) + .opacity(isInputBlocked ? 0.4 : 1) + .allowsHitTesting(!isInputBlocked) + .animation(.easeInOut(duration: 0.3), value: isInputBlocked) + } + .transition(.move(edge: .bottom)) + } + } + .frame(height: controlsPanelHeight, alignment: .top) + .background { + Color(.systemGroupedBackground) + .ignoresSafeArea(edges: .bottom) + } + .overlay(alignment: .top) { + if controlsPanelHeight > 0 { + Rectangle() + .fill(Color(.opaqueSeparator)) + .frame(height: 0.5) + } + } + .animation(.easeOut(duration: 0.25), value: isSolved) + .ignoresSafeArea(edges: .bottom) + } + + private var controlsPanelHeight: CGFloat { + isSolved || showsCustomKeyboard ? KeyboardView.standardHeight : 0 + } + + private var showsCustomKeyboard: Bool { + !inputMonitor.isConnected + } + + private func handleHardwareKeyboardEvent(_ event: HardwareKeyboardEvent) -> Bool { + guard !isSolved, !isInputBlocked else { return false } + + // Cmd+Z undoes, Shift-Cmd-Z redoes. Caught before the letter switch so + // the modified press isn't read as typing a "Z". + if event.keyCode == .keyboardZ, event.modifierFlags.contains(.command) { + guard !session.isRebusActive else { return false } + if event.modifierFlags.contains(.shift) { + session.redo() + } else { + session.undo() + } + return true + } + + switch event.keyCode { + case .keyboardA, .keyboardB, .keyboardC, .keyboardD, .keyboardE, + .keyboardF, .keyboardG, .keyboardH, .keyboardI, .keyboardJ, + .keyboardK, .keyboardL, .keyboardM, .keyboardN, .keyboardO, + .keyboardP, .keyboardQ, .keyboardR, .keyboardS, .keyboardT, + .keyboardU, .keyboardV, .keyboardW, .keyboardX, .keyboardY, + .keyboardZ: + guard !event.modifierFlags.contains(.command), + !event.modifierFlags.contains(.control), + !event.modifierFlags.contains(.alternate), + let letter = hardwareKeyboardLetter(from: event) else { + return false + } + if session.isRebusActive { + session.appendRebusLetter(letter) + } else { + session.enter(letter) + } + return true + + case .keyboardDeleteOrBackspace, .keyboardDeleteForward: + if session.isRebusActive { + session.deleteRebusLetter() + } else { + session.deleteBackward() + } + return true + + case .keyboardLeftArrow: + guard !session.isRebusActive else { return false } + if event.modifierFlags.contains(.command) { + session.goToPreviousWord() + return true + } + moveWithHardwareArrow(direction: .across) { + session.goToPreviousLetter() + } + return true + + case .keyboardRightArrow: + guard !session.isRebusActive else { return false } + if event.modifierFlags.contains(.command) { + session.goToNextWord() + return true + } + moveWithHardwareArrow(direction: .across) { + session.goToNextLetter() + } + return true + + case .keyboardUpArrow: + guard !session.isRebusActive else { return false } + moveWithHardwareArrow(direction: .down) { + session.goToPreviousLetter() + } + return true + + case .keyboardDownArrow: + guard !session.isRebusActive else { return false } + moveWithHardwareArrow(direction: .down) { + session.goToNextLetter() + } + return true + + case .keyboardTab: + guard !session.isRebusActive else { return false } + if event.modifierFlags.contains(.shift) { + session.goToPreviousClue() + } else { + session.goToNextClue() + } + return true + + case .keyboardSpacebar: + guard !session.isRebusActive else { return false } + session.toggleDirection() + return true + + case .keyboardReturnOrEnter: + if session.isRebusActive { + session.commitRebus() + } else { + session.toggleDirection() + } + return true + + case .keyboardEscape: + if session.isRebusActive { + session.commitRebus() + return true + } + return false + + default: + return false + } + } + + private func hardwareKeyboardLetter(from event: HardwareKeyboardEvent) -> String? { + let scalars = event.charactersIgnoringModifiers.unicodeScalars + guard scalars.count == 1, let scalar = scalars.first else { return nil } + + switch scalar.value { + case 65...90, 97...122: + return String(Character(scalar)).uppercased() + default: + return nil + } + } + + private func moveWithHardwareArrow(direction: Puzzle.Direction, move: () -> Void) { + if session.direction != direction { + let previousDirection = session.direction + session.setDirection(direction) + if session.direction != previousDirection { + return + } + } + + move() + } + + private func leaveSharedGame() async { + guard let shareController else { return } + do { + try await shareController.leaveShare(gameID: session.mutator.gameID) + dismiss() + } catch { + leaveError = String(describing: error) + } + } +} + +private struct RebusModal: View { + let text: String + + var body: some View { + Text(text.isEmpty ? " " : text) + .font(.system(size: 32, weight: .semibold, design: .rounded)) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, minHeight: 56) + .padding(.horizontal, 16) + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .padding(20) + .frame(maxWidth: .infinity) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } +} + +private struct ControlsView<Content: View>: View { + let height: CGFloat + @ViewBuilder var content: () -> Content + + var body: some View { + VStack(spacing: 0) { + content() + .frame(height: height) + Color(.systemGroupedBackground) + } + .background(Color(.systemGroupedBackground)) + .ignoresSafeArea(edges: .bottom) + } +} diff --git a/Crossmate/Views/SuccessPanel.swift b/Crossmate/Views/Puzzle/SuccessPanel.swift diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -1,1753 +0,0 @@ -import SwiftUI - -private enum RevealScope { - case square - case word - case puzzle - - var title: String { - switch self { - case .square: "Reveal Square?" - case .word: "Reveal Word?" - case .puzzle: "Reveal Puzzle?" - } - } - - var message: String { - switch self { - case .square: "This will reveal the current square." - case .word: "This will reveal the current word." - case .puzzle: "This will reveal the entire puzzle and mark it complete." - } - } -} - -struct PuzzleView: View { - @Bindable var session: PlayerSession - var shareController: ShareController? = nil - let roster: PlayerRoster - var onComplete: ((_ notifyPeers: Bool) -> Void)? = nil - var onResign: (() throws -> Void)? = nil - var onDelete: (() throws -> Void)? = nil - /// Loads the finished game's merged journal for the finish-banner replay - /// scrubber. Defaults to `.unavailable` so previews/tests need not wire it. - var loadReplay: () async -> JournalReplayResult = { .unavailable } - /// Cells a peer filled or cleared since this player last viewed the puzzle, - /// mapped to the writing author. Read once on the open arm beat. Defaults to - /// empty so previews/tests need not wire it. - var loadRecentChanges: () -> [GridPosition: String] = { [:] } - /// Stamps this game's last-viewed timestamp (device-local). Called when the - /// away-change borders are acknowledged. Defaults to a no-op. - var markPuzzleViewed: () -> Void = {} - @Environment(InputMonitor.self) private var inputMonitor - @Environment(PlayerPreferences.self) private var preferences - @Environment(AnnouncementCenter.self) private var announcements - @Environment(\.dismiss) private var dismiss - @State private var isRenaming = false - @State private var renameDraft = "" - @State private var showErrorsAlert = false - @State private var isConfirmingResign = false - @State private var isConfirmingDelete = false - @State private var isConfirmingLeave = false - @State private var isConfirmingReveal = false - @State private var pendingRevealScope: RevealScope = .square - @State private var leaveError: String? - @State private var destructiveActionError: String? - @State private var isShowingShareSheet = false - @State private var hasSolved = false - @State private var replay = ReplayControls() - @State private var padLayout: PadLayout? - /// The shared open "arm" beat: flips a moment after open so the banner and - /// the "changed while you were away" borders reveal together. - @State private var isArmed = false - @Environment(\.engagementStatus) private var engagementStatus - - private enum PadLayout { - case landscape - case portrait - } - - private func swatchImage(for color: PlayerColor) -> Image { - let tint = UIColor(color.tint) - let base = UIImage(systemName: "circle.fill") ?? UIImage() - return Image(uiImage: base.withTintColor(tint, renderingMode: .alwaysOriginal)) - } - - private struct TitleParts { - let title: String - let subtitle: String? - } - - private var titleParts: TitleParts { - let title = session.puzzle.title - let formattedDate = session.puzzle.date?.formatted(date: .long, time: .omitted) - let subtitle: String? - if let publisher = session.puzzle.publisher, let formattedDate { - subtitle = "\(publisher) · \(formattedDate)" - } else if let publisher = session.puzzle.publisher { - subtitle = publisher - } else { - subtitle = formattedDate - } - return TitleParts(title: title, subtitle: subtitle) - } - - // Latched completion counts as solved for the read-only presentation - // (hides the keyboard, shows the finish panel, disables the controls) even - // when the locally merged grid drifted and no longer reads `.solved`. - private var isSolved: Bool { hasSolved || session.mutator.isCompleted } - - /// Whether a sticky, input-blocking announcement (currently only - /// access revocation) is showing for this game. Greys out the custom - /// keyboard and makes the hardware-key handler a no-op. - private var isInputBlocked: Bool { - announcements.isInputBlocked(forGame: session.mutator.gameID) - } - - var body: some View { - Group { - switch padLayout { - case .landscape: - landscapePadLayout - case .portrait: - portraitPadLayout - case .none: - phoneLayout - } - } - .background(Color(.systemBackground)) - .background { - HardwareKeyboardInputView(onPress: handleHardwareKeyboardEvent) - .frame(width: 0, height: 0) - .allowsHitTesting(false) - } - .ignoresSafeArea(.keyboard) - .modifier(PuzzleToolbarModifier( - session: session, - roster: roster, - shareController: shareController, - isSolved: isSolved, - canResign: onResign != nil, - canDelete: onDelete != nil, - isRenaming: $isRenaming, - renameDraft: $renameDraft, - isConfirmingResign: $isConfirmingResign, - isConfirmingDelete: $isConfirmingDelete, - isConfirmingLeave: $isConfirmingLeave, - isConfirmingReveal: $isConfirmingReveal, - pendingRevealScope: $pendingRevealScope, - isShowingShareSheet: $isShowingShareSheet - )) - .modifier(PuzzleLifecycleModifier( - session: session, - roster: roster, - hasSolved: $hasSolved, - onCompletionEvent: handleCompletionEvent, - onSolvedOnAppear: { - onComplete?(false) - } - )) - .modifier(PuzzlePresentationModifier( - session: session, - shareController: shareController, - isRenaming: $isRenaming, - renameDraft: $renameDraft, - showErrorsAlert: $showErrorsAlert, - isConfirmingResign: $isConfirmingResign, - isConfirmingDelete: $isConfirmingDelete, - isConfirmingLeave: $isConfirmingLeave, - isConfirmingReveal: $isConfirmingReveal, - pendingRevealScope: $pendingRevealScope, - leaveError: $leaveError, - destructiveActionError: $destructiveActionError, - isShowingShareSheet: $isShowingShareSheet, - performResign: performResign, - performDelete: performDelete, - leaveSharedGame: leaveSharedGame - )) - .onGeometryChange(for: CGSize.self) { proxy in - proxy.size - } action: { newSize in - updateLayoutTrait(for: newSize) - } - .onAppear { - session.onRecentChangesAcknowledged = markPuzzleViewed - } - .task(id: session.mutator.gameID) { - // The shared open beat. A short hold lets the puzzle settle and the - // on-open sync land; then we arm the banner and capture — once — - // which cells a peer changed while we were away, so both reveal - // together. Moves that arrive after this are live activity (peer - // cursor tints), not part of the away-summary. - isArmed = false - try? await Task.sleep(for: .milliseconds(750)) - isArmed = true - if session.mutator.isShared { - session.recentChanges = loadRecentChanges() - } - } - } - - private var phoneLayout: some View { - VStack(spacing: 0) { - puzzleArea - controlsArea(showClueBar: true) - } - } - - private var landscapePadLayout: some View { - VStack(spacing: 0) { - HStack(spacing: 0) { - VStack(spacing: 0) { - if !isSolved { - PuzzleScoreboard(session: session, roster: roster) - - Divider() - } - - ClueList(session: session, presentation: .sidebar, replayFrame: replay.frame) - } - .frame(minWidth: 300, idealWidth: 360, maxWidth: 420) - .background(Color(.secondarySystemBackground)) - - Divider() - .ignoresSafeArea(edges: .top) - - puzzleArea - .padding(.bottom, 12) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - controlsArea(showClueBar: false) - } - } - - private var portraitPadLayout: some View { - VStack(spacing: 0) { - WeightedVStack(weights: [3, 1]) { - puzzleArea - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.bottom, 12) - - VStack(spacing: 0) { - Divider() - - HStack(alignment: .top, spacing: 0) { - if !isSolved { - PuzzleScoreboard(session: session, roster: roster) - .frame(minWidth: 240, idealWidth: 280, maxWidth: 320) - - Divider() - } - - ClueList(session: session, presentation: .sidebar, replayFrame: replay.frame) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .background(Color(.secondarySystemBackground)) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - controlsArea(showClueBar: false) - } - } - - private func updateLayoutTrait(for size: CGSize) { - guard UIDevice.current.userInterfaceIdiom == .pad, size != .zero else { - padLayout = nil - return - } - padLayout = size.width > size.height ? .landscape : .portrait - } - - private func performResign() { - do { - try onResign?() - dismiss() - } catch { - destructiveActionError = String(describing: error) - } - } - - private func performDelete() { - do { - try onDelete?() - dismiss() - } catch { - destructiveActionError = String(describing: error) - } - } - - private func handleCompletionEvent(_ event: PlayerSession.CompletionEvent) { - switch (event.origin, event.state) { - case (_, .incomplete): - break - case (.observed, .filledWithErrors): - // A collaborator's wrong entry must not interrupt the local solver. - break - case (.local, .filledWithErrors): - showErrorsAlert = true - case (.local, .solved): - guard !hasSolved else { return } - hasSolved = true - if session.isPencilMode { - session.togglePencil() - } - Task { @MainActor in - onComplete?(true) - } - case (.observed, .solved): - guard !hasSolved else { return } - hasSolved = true - onComplete?(false) - } - } - - private var puzzleArea: some View { - ZStack { - VStack(spacing: 4) { - PuzzleHeader( - session: session, - roster: roster, - title: titleParts.title, - subtitle: titleParts.subtitle, - showsScoreboard: padLayout == nil, - gameID: session.mutator.gameID, - isEngagementLive: engagementStatus?.isLive(gameID: session.mutator.gameID) == true, - isArmed: isArmed - ) - GridView( - session: session, - roster: roster, - showsSharedAnnotations: session.mutator.isShared, - showsPeerCursors: !isSolved, - replayFrame: replay.frame - ) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.top, -8) - - if session.isRebusActive { - Color.black.opacity(0.35) - .ignoresSafeArea(edges: .top) - .contentShape(Rectangle()) - .onTapGesture { - session.commitRebus() - } - RebusModal(text: session.rebusBuffer) - .padding(.horizontal) - .contentShape(Rectangle()) - .onTapGesture { /* swallow */ } - } - } - } - - private func controlsArea(showClueBar: Bool) -> some View { - VStack(spacing: 0) { - if showClueBar { - ClueBarSlot(session: session, replayFrame: replay.frame) - } - controlsPanel - .frame(height: controlsPanelHeight) - } - } - - private var controlsPanel: some View { - ZStack(alignment: .top) { - if isSolved { - ControlsView(height: controlsPanelHeight) { - SuccessPanel( - session: session, - roster: roster, - replay: replay, - loadReplay: loadReplay - ) - } - .transition(.move(edge: .bottom)) - } else if showsCustomKeyboard { - ControlsView(height: controlsPanelHeight) { - KeyboardView(session: session, showsNavigationKeys: padLayout != nil) - .opacity(isInputBlocked ? 0.4 : 1) - .allowsHitTesting(!isInputBlocked) - .animation(.easeInOut(duration: 0.3), value: isInputBlocked) - } - .transition(.move(edge: .bottom)) - } - } - .frame(height: controlsPanelHeight, alignment: .top) - .background { - Color(.systemGroupedBackground) - .ignoresSafeArea(edges: .bottom) - } - .overlay(alignment: .top) { - if controlsPanelHeight > 0 { - Rectangle() - .fill(Color(.opaqueSeparator)) - .frame(height: 0.5) - } - } - .animation(.easeOut(duration: 0.25), value: isSolved) - .ignoresSafeArea(edges: .bottom) - } - - private var controlsPanelHeight: CGFloat { - isSolved || showsCustomKeyboard ? KeyboardView.standardHeight : 0 - } - - private var showsCustomKeyboard: Bool { - !inputMonitor.isConnected - } - - private func handleHardwareKeyboardEvent(_ event: HardwareKeyboardEvent) -> Bool { - guard !isSolved, !isInputBlocked else { return false } - - // Cmd+Z undoes, Shift-Cmd-Z redoes. Caught before the letter switch so - // the modified press isn't read as typing a "Z". - if event.keyCode == .keyboardZ, event.modifierFlags.contains(.command) { - guard !session.isRebusActive else { return false } - if event.modifierFlags.contains(.shift) { - session.redo() - } else { - session.undo() - } - return true - } - - switch event.keyCode { - case .keyboardA, .keyboardB, .keyboardC, .keyboardD, .keyboardE, - .keyboardF, .keyboardG, .keyboardH, .keyboardI, .keyboardJ, - .keyboardK, .keyboardL, .keyboardM, .keyboardN, .keyboardO, - .keyboardP, .keyboardQ, .keyboardR, .keyboardS, .keyboardT, - .keyboardU, .keyboardV, .keyboardW, .keyboardX, .keyboardY, - .keyboardZ: - guard !event.modifierFlags.contains(.command), - !event.modifierFlags.contains(.control), - !event.modifierFlags.contains(.alternate), - let letter = hardwareKeyboardLetter(from: event) else { - return false - } - if session.isRebusActive { - session.appendRebusLetter(letter) - } else { - session.enter(letter) - } - return true - - case .keyboardDeleteOrBackspace, .keyboardDeleteForward: - if session.isRebusActive { - session.deleteRebusLetter() - } else { - session.deleteBackward() - } - return true - - case .keyboardLeftArrow: - guard !session.isRebusActive else { return false } - if event.modifierFlags.contains(.command) { - session.goToPreviousWord() - return true - } - moveWithHardwareArrow(direction: .across) { - session.goToPreviousLetter() - } - return true - - case .keyboardRightArrow: - guard !session.isRebusActive else { return false } - if event.modifierFlags.contains(.command) { - session.goToNextWord() - return true - } - moveWithHardwareArrow(direction: .across) { - session.goToNextLetter() - } - return true - - case .keyboardUpArrow: - guard !session.isRebusActive else { return false } - moveWithHardwareArrow(direction: .down) { - session.goToPreviousLetter() - } - return true - - case .keyboardDownArrow: - guard !session.isRebusActive else { return false } - moveWithHardwareArrow(direction: .down) { - session.goToNextLetter() - } - return true - - case .keyboardTab: - guard !session.isRebusActive else { return false } - if event.modifierFlags.contains(.shift) { - session.goToPreviousClue() - } else { - session.goToNextClue() - } - return true - - case .keyboardSpacebar: - guard !session.isRebusActive else { return false } - session.toggleDirection() - return true - - case .keyboardReturnOrEnter: - if session.isRebusActive { - session.commitRebus() - } else { - session.toggleDirection() - } - return true - - case .keyboardEscape: - if session.isRebusActive { - session.commitRebus() - return true - } - return false - - default: - return false - } - } - - private func hardwareKeyboardLetter(from event: HardwareKeyboardEvent) -> String? { - let scalars = event.charactersIgnoringModifiers.unicodeScalars - guard scalars.count == 1, let scalar = scalars.first else { return nil } - - switch scalar.value { - case 65...90, 97...122: - return String(Character(scalar)).uppercased() - default: - return nil - } - } - - private func moveWithHardwareArrow(direction: Puzzle.Direction, move: () -> Void) { - if session.direction != direction { - let previousDirection = session.direction - session.setDirection(direction) - if session.direction != previousDirection { - return - } - } - - move() - } - - private func leaveSharedGame() async { - guard let shareController else { return } - do { - try await shareController.leaveShare(gameID: session.mutator.gameID) - dismiss() - } catch { - leaveError = String(describing: error) - } - } -} - -private struct PuzzleScoreboard: View { - @Bindable var session: PlayerSession - let roster: PlayerRoster - var layout: Layout = .vertical - @Environment(PlayerPreferences.self) private var preferences - @ScaledMetric(relativeTo: .subheadline) private var horizontalHeaderHeight: CGFloat = 56 - - enum Layout { - /// Side-panel style: stacked rows under a "Players" heading. - case vertical - /// Paged-header style: a horizontally scrollable strip of player - /// chips, sized to scroll past two players when more arrive. - case horizontal - } - - private struct Score: Identifiable { - let authorID: String? - let name: String - let color: PlayerColor? - let filledCount: Int - - var id: String { authorID ?? "unattributed" } - } - - private var fillableCellCount: Int { - session.puzzle.cells.reduce(0) { count, row in - count + row.filter { !$0.isBlock }.count - } - } - - private var filledCellCount: Int { - var count = 0 - for r in 0..<session.puzzle.height { - for c in 0..<session.puzzle.width { - guard !session.puzzle.cells[r][c].isBlock else { continue } - if !session.game.squares[r][c].entry.isEmpty { - count += 1 - } - } - } - return count - } - - private var revealedSquareCount: Int { - var count = 0 - for r in 0..<session.puzzle.height { - for c in 0..<session.puzzle.width { - guard !session.puzzle.cells[r][c].isBlock else { continue } - if session.game.squares[r][c].mark.isRevealed { - count += 1 - } - } - } - return count - } - - private var remainingCount: Int { - max(0, fillableCellCount - filledCellCount) - } - - private var remainingPhrase: String { - switch remainingCount { - case 0: - return "no squares to go" - case 1: - return "1 square to go" - default: - return "\(remainingCount) squares to go" - } - } - - private var revealedPhrase: String { - switch revealedSquareCount { - case 0: - return "No squares revealed" - case 1: - return "1 square revealed" - default: - return "\(revealedSquareCount) squares revealed" - } - } - - private var progressText: String { - if revealedSquareCount > 0 { - return "\(revealedPhrase), \(remainingPhrase)" - } - switch remainingCount { - case 0: - return "No squares to go" - case 1: - return "1 square to go" - default: - return "\(remainingCount) squares to go" - } - } - - private var scores: [Score] { - var counts: [String?: Int] = [:] - for r in 0..<session.puzzle.height { - for c in 0..<session.puzzle.width { - guard !session.puzzle.cells[r][c].isBlock else { continue } - let square = session.game.squares[r][c] - guard !square.entry.isEmpty, !square.mark.isRevealed else { continue } - counts[normalizedAuthorID(square.letterAuthorID), default: 0] += 1 - } - } - - let entries = roster.entries - let usesLocalFallback = entries.isEmpty - let entryByAuthorID = Dictionary(uniqueKeysWithValues: entries.map { ($0.authorID, $0) }) - let rosterAuthorIDs = Set(entries.map(\.authorID)) - - let rosterScores: [Score] - if usesLocalFallback { - rosterScores = [ - Score( - authorID: nil, - name: preferences.name, - color: preferences.color, - filledCount: counts[nil] ?? 0 - ) - ] - } else { - rosterScores = entries.map { entry in - Score( - authorID: entry.authorID, - name: entry.name, - color: entry.color, - filledCount: counts[entry.authorID] ?? 0 - ) - } - } - - let extraScores = counts.compactMap { authorID, count -> Score? in - if let authorID, rosterAuthorIDs.contains(authorID) { - return nil - } - if authorID == nil && usesLocalFallback { - return nil - } - if let authorID, let entry = entryByAuthorID[authorID] { - return Score(authorID: authorID, name: entry.name, color: entry.color, filledCount: count) - } - if authorID == nil { - // A `nil` author key only arises with remote players present - // (see `normalizedAuthorID`): an authorless square, e.g. a cell - // sealed to the solution at completion before its author's - // letter arrived. It belongs to no player, so drop it rather - // than tallying an "Unattributed" entry. - return nil - } - return Score(authorID: authorID, name: "Player", color: nil, filledCount: count) - } - - return (rosterScores + extraScores) - .sorted { - if $0.filledCount != $1.filledCount { return $0.filledCount > $1.filledCount } - return $0.name < $1.name - } - } - - private func normalizedAuthorID(_ authorID: String?) -> String? { - guard let authorID else { - return roster.entries.contains(where: { !$0.isLocal }) ? nil : roster.localAuthorID - } - return authorID - } - - @ViewBuilder - var body: some View { - switch layout { - case .vertical: - verticalBody - case .horizontal: - horizontalBody - } - } - - private var verticalBody: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Players") - .font(.headline) - - VStack(alignment: .leading, spacing: 6) { - ForEach(scores) { score in - scoreRow(score) - } - - Text(progressText) - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.top, 10) - .frame(maxWidth: .infinity, alignment: .center) - } - } - .padding(.horizontal, 18) - .padding(.vertical, 14) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var chipFlow: some View { - FlowLayout(spacing: 18, lineSpacing: 8) { - ForEach(scores) { score in - scoreChip(score) - } - } - .padding(.horizontal, 18) - .padding(.vertical, 4) - } - - private var horizontalBody: some View { - // A titled "Players" section mirroring the iPad side panel - // (verticalBody). It sizes to its content and sits top-anchored - // in a ScrollView, so it reads as a deliberate header section - // rather than a stray chip, and scrolls when there are enough - // players to overflow the band — no centring tricks required. - ScrollView(.vertical, showsIndicators: false) { - VStack(spacing: 6) { - Text("Players") - .font(.subheadline.weight(.semibold)) - chipFlow - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - } - .frame(height: horizontalHeaderHeight) - } - - private func scoreChip(_ score: Score) -> some View { - HStack(spacing: 6) { - Circle() - .fill(score.color?.tint ?? Color.secondary) - .frame(width: 8, height: 8) - Text(score.name) - .font(.subheadline) - .lineLimit(1) - Text("\(score.filledCount)") - .font(.subheadline.monospacedDigit().weight(.semibold)) - } - .accessibilityElement(children: .combine) - } - - private func scoreRow(_ score: Score) -> some View { - HStack(spacing: 8) { - Circle() - .fill(score.color?.tint ?? Color.secondary) - .frame(width: 8, height: 8) - Text(score.name) - .font(.subheadline) - .lineLimit(1) - Spacer(minLength: 8) - Text("\(score.filledCount)") - .font(.subheadline.monospacedDigit().weight(.semibold)) - } - .accessibilityElement(children: .combine) - } -} - -private struct PuzzleToolbarModifier: ViewModifier { - let session: PlayerSession - let roster: PlayerRoster - let shareController: ShareController? - let isSolved: Bool - let canResign: Bool - let canDelete: Bool - @Binding var isRenaming: Bool - @Binding var renameDraft: String - @Binding var isConfirmingResign: Bool - @Binding var isConfirmingDelete: Bool - @Binding var isConfirmingLeave: Bool - @Binding var isConfirmingReveal: Bool - @Binding var pendingRevealScope: RevealScope - @Binding var isShowingShareSheet: Bool - @Environment(PlayerPreferences.self) private var preferences - @AppStorage("debugMode") private var debugMode = false - - func body(content: Content) -> some View { - content.toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { - pencilButton - entryMenu - hintsMenu - playersMenu - } - } - } - - private func swatchImage(for color: PlayerColor) -> Image { - let tint = UIColor(color.tint) - let base = UIImage(systemName: "circle.fill") ?? UIImage() - return Image(uiImage: base.withTintColor(tint, renderingMode: .alwaysOriginal)) - } - - private var pencilButton: some View { - Button { - session.togglePencil() - } label: { - Image(systemName: "pencil") - .foregroundStyle(pencilButtonForeground) - .padding(6) - .glassEffect( - !isSolved && session.isPencilMode - ? .regular.tint(preferences.color.tint) - : .identity, - in: Circle() - ) - } - .accessibilityLabel("Pencil") - .disabled(isSolved) - } - - private var pencilButtonForeground: Color { - if isSolved { - return .secondary - } - return session.isPencilMode ? .white : .primary - } - - private var entryMenu: some View { - Menu { - Section { - Button("Undo Move") { session.undo() } - .disabled(!session.canUndo) - Button("Redo Move") { session.redo() } - .disabled(!session.canRedo) - } - - Section { - Button("Enter Rebus") { session.startRebus() } - Button("Toggle Direction") { session.toggleDirection() } - } - - if debugMode { - Section { - NavigationLink { - DiagnosticsView() - } label: { - Text("Diagnostics Log") - } - } - } - - Section { - Button("Clear Word") { session.clearCurrentWord() } - Button("Clear Puzzle", role: .destructive) { session.clearPuzzle() } - } - } label: { - Label("Entry", systemImage: "squareshape.split.2x2") - } - .disabled(isSolved) - } - - private var hintsMenu: some View { - Menu { - Section { - Button("Check Square") { session.checkSquare() } - Button("Check Word") { session.checkCurrentWord() } - Button("Check Puzzle") { session.checkPuzzle() } - } - Section { - Button("Reveal Square") { confirmReveal(.square) } - Button("Reveal Word") { confirmReveal(.word) } - Button("Reveal Puzzle") { confirmReveal(.puzzle) } - } - } label: { - Label("Hints", systemImage: "lightbulb") - } - .disabled(isSolved) - } - - private func confirmReveal(_ scope: RevealScope) { - pendingRevealScope = scope - isConfirmingReveal = true - } - - private var playersMenu: some View { - Menu { - playerRosterSection - playerPreferencesSection - shareSection - puzzleDestructiveSection - } label: { - Label("Players", systemImage: "person.2") - } - .disabled(isSolved) - } - - @ViewBuilder - private var playerRosterSection: some View { - Section { - if !roster.entries.isEmpty { - ForEach(roster.entries) { entry in - Button {} label: { - Label { - Text(entry.isLocal ? "\(entry.name) (you)" : entry.name) - } icon: { - swatchImage(for: entry.color) - } - } - .disabled(true) - } - } else { - Button {} label: { - Label { - Text(preferences.name) - } icon: { - swatchImage(for: preferences.color) - } - } - .disabled(true) - } - } - } - - private var playerPreferencesSection: some View { - Section { - Menu("Change Colour") { - ForEach(PlayerColor.palette) { color in - Button { - preferences.color = color - // Friend colours are derived with the local user's - // colour reserved, so refreshing re-derives and bumps - // any friend that now collides with the new choice. - Task { await roster.refresh() } - } label: { - Label { - Text(color.id == preferences.colorID ? "\(color.name) ✓" : color.name) - } icon: { - swatchImage(for: color) - } - } - } - } - - Button("Change Name") { - renameDraft = preferences.name - isRenaming = true - } - } - } - - @ViewBuilder - private var shareSection: some View { - if shareController != nil { - Section { - Button { - isShowingShareSheet = true - } label: { - Text("Share Game") - } - .disabled(!session.mutator.isOwned) - } - } - } - - private var puzzleDestructiveSection: some View { - Section { - Button("Resign Game", role: .destructive) { - isConfirmingResign = true - } - .disabled(isSolved || !canResign) - - if session.mutator.isShared && !session.mutator.isOwned { - Button("Leave Game", role: .destructive) { - isConfirmingLeave = true - } - .disabled(shareController == nil) - } else { - Button("Delete Game", role: .destructive) { - isConfirmingDelete = true - } - .disabled(!canDelete) - } - } - } -} - -private struct PuzzleLifecycleModifier: ViewModifier { - let session: PlayerSession - let roster: PlayerRoster - @Binding var hasSolved: Bool - let onCompletionEvent: (PlayerSession.CompletionEvent) -> Void - let onSolvedOnAppear: () -> Void - - func body(content: Content) -> some View { - content - .task { - await roster.refresh() - } - .onAppear { - if session.game.completionState == .solved { - hasSolved = true - onSolvedOnAppear() - } - } - .onChange(of: session.completionEvent) { _, newValue in - guard let newValue else { return } - onCompletionEvent(newValue) - } - } -} - -private struct PuzzlePresentationModifier: ViewModifier { - let session: PlayerSession - let shareController: ShareController? - @Binding var isRenaming: Bool - @Binding var renameDraft: String - @Binding var showErrorsAlert: Bool - @Binding var isConfirmingResign: Bool - @Binding var isConfirmingDelete: Bool - @Binding var isConfirmingLeave: Bool - @Binding var isConfirmingReveal: Bool - @Binding var pendingRevealScope: RevealScope - @Binding var leaveError: String? - @Binding var destructiveActionError: String? - @Binding var isShowingShareSheet: Bool - let performResign: () -> Void - let performDelete: () -> Void - let leaveSharedGame: () async -> Void - @Environment(PlayerPreferences.self) private var preferences - - func body(content: Content) -> some View { - content - .alert("Not Quite Right", isPresented: $showErrorsAlert) { - Button("OK", role: .cancel) {} - } message: { - Text("One or more squares are incorrect.") - } - .alert("Resign Puzzle?", isPresented: $isConfirmingResign) { - Button("Resign", role: .destructive) { - performResign() - } - Button("Cancel", role: .cancel) {} - } message: { - Text("This will reveal the puzzle and mark it complete.") - } - .alert("Delete Puzzle?", isPresented: $isConfirmingDelete) { - Button("Delete", role: .destructive) { - performDelete() - } - Button("Cancel", role: .cancel) {} - } message: { - deleteConfirmationMessage - } - .alert("Leave Puzzle?", isPresented: $isConfirmingLeave) { - Button("Leave", role: .destructive) { - Task { await leaveSharedGame() } - } - Button("Cancel", role: .cancel) {} - } message: { - Text("You will lose access to \"\(session.puzzle.title)\".") - } - .alert(pendingRevealScope.title, isPresented: $isConfirmingReveal) { - Button("Reveal", role: .destructive) { - performReveal(pendingRevealScope) - } - Button("Cancel", role: .cancel) {} - } message: { - Text(pendingRevealScope.message) - } - .alert( - "Couldn't Leave", - isPresented: .init( - get: { leaveError != nil }, - set: { if !$0 { leaveError = nil } } - ), - presenting: leaveError - ) { _ in - Button("OK", role: .cancel) {} - } message: { message in - Text(message) - } - .alert( - "Couldn't Update Puzzle", - isPresented: .init( - get: { destructiveActionError != nil }, - set: { if !$0 { destructiveActionError = nil } } - ), - presenting: destructiveActionError - ) { _ in - Button("OK", role: .cancel) {} - } message: { message in - Text(message) - } - .alert("Change Name", isPresented: $isRenaming) { - TextField("Name", text: $renameDraft) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - Button("Cancel", role: .cancel) {} - Button("Save") { - let trimmed = renameDraft.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - preferences.name = trimmed - } - } - .keyboardShortcut(.defaultAction) - } message: { - Text("Enter the name other players will see.") - } - .sheet(isPresented: $isShowingShareSheet) { - if let shareController { - GameShareSheet( - gameID: session.mutator.gameID, - title: session.puzzle.title, - shareController: shareController - ) - } - } - } - - private func performReveal(_ scope: RevealScope) { - switch scope { - case .square: session.revealSquare() - case .word: session.revealCurrentWord() - case .puzzle: session.revealPuzzle() - } - } - - private var deleteConfirmationMessage: Text { - if session.mutator.isOwned && session.mutator.isShared { - Text("This will permanently delete \"\(session.puzzle.title)\" from iCloud for everyone.") - } else { - Text("This will permanently delete \"\(session.puzzle.title)\" and all progress.") - } - } -} - -/// Swipeable header that sits above the grid. Page 1 is the title, the -/// last page is the credits, and on iPhone a scoreboard page sits between -/// them (iPad shows the scoreboard permanently in the side panel, so it is -/// omitted here). A fixed height is required because `.page` style fills -/// its container rather than sizing to content. -private struct PuzzleHeader: View { - @Bindable var session: PlayerSession - let roster: PlayerRoster - let title: String - let subtitle: String? - let showsScoreboard: Bool - let gameID: UUID - let isEngagementLive: Bool - /// The shared open "arm" beat, owned by `PuzzleView` so the banner and the - /// grid's "changed while you were away" borders reveal together. Until it - /// flips (a moment after open), the title is the only thing on screen; - /// then banner posts — including a session summary that arrived during the - /// hold — animate in. - let isArmed: Bool - @Environment(AnnouncementCenter.self) private var announcements - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @State private var selection: Page = .title - - private enum Page: Hashable { - case title - case scoreboard - case credits - } - - /// Reconstructed as "© <year> <publisher>" from the puzzle's date and - /// publisher, falling back to whatever pieces exist, and finally to the - /// raw copyright string parsed from the source. - private var copyrightLine: String? { - let year = session.puzzle.date.map { - Calendar.current.component(.year, from: $0) - } - switch (year, session.puzzle.publisher) { - case let (year?, publisher?): - return "© \(year) \(publisher)" - case let (year?, nil): - return "© \(year)" - case let (nil, publisher?): - return "© \(publisher)" - case (nil, nil): - return session.puzzle.copyright - } - } - - private var hasCredits: Bool { - session.puzzle.author != nil || copyrightLine != nil - } - - private var pages: [Page] { - var result: [Page] = [.title] - if showsScoreboard { result.append(.scoreboard) } - if hasCredits { result.append(.credits) } - return result - } - - /// Above the default text size the clue bar below the grid grows to fit - /// the (must-read) clue, squeezing the grid. The title/scoreboard/credits - /// shown here are the least important text on screen, so the header yields - /// its own height as type scales up — shedding a few points per step down - /// to a legible-enough floor — and hands that space back to the grid. The - /// text inside just truncates within the smaller box. At or below the - /// default size the comfortable full height is preserved. - private var headerHeight: CGFloat { - let sizes = DynamicTypeSize.allCases - guard let current = sizes.firstIndex(of: dynamicTypeSize), - let baseline = sizes.firstIndex(of: .large) - else { return 80 } - let stepsAboveDefault = max(0, current - baseline) - return max(48, 80 - CGFloat(stepsAboveDefault) * 6) - } - - var body: some View { - let visibleAnnouncement = isArmed - ? announcements.current(forGame: gameID) - : nil - Group { - // Title/scoreboard/credits is the baseline — it renders - // immediately on open and stays put. After the open beat we - // start reacting to announcements: the banner slides down - // over the title and slides back out on dismissal. Both - // branches occupy the same fixed-height frame so the grid - // below doesn't jump. - if let announcement = visibleAnnouncement { - AnnouncementBanner( - announcement: announcement, - fillsAvailableHeight: true - ) { - announcements.dismiss(id: announcement.id) - } - .padding(.horizontal, 12) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } else { - headerPages - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - } - .frame(height: headerHeight) - .padding(.bottom, 14) - .animation(.easeInOut(duration: 0.3), value: visibleAnnouncement) - .animation(.easeInOut(duration: 0.2), value: isEngagementLive) - } - - private var headerPages: some View { - VStack(spacing: 10) { - TabView(selection: $selection) { - ForEach(pages, id: \.self) { page in - pageContent(page) - .tag(page) - } - } - .tabViewStyle(.page(indexDisplayMode: .never)) - - if pages.count > 1 { - HStack(spacing: 6) { - ForEach(pages, id: \.self) { page in - Circle() - .fill(page == selection ? Color.secondary : Color.secondary.opacity(0.3)) - .frame(width: 6, height: 6) - } - } - .animation(.easeInOut(duration: 0.2), value: selection) - } - } - } - - @ViewBuilder - private func pageContent(_ page: Page) -> some View { - switch page { - case .title: - PuzzleTitle(title: title, subtitle: subtitle, isEngagementLive: isEngagementLive) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - case .scoreboard: - PuzzleScoreboard(session: session, roster: roster, layout: .horizontal) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - case .credits: - PuzzleCredits(author: session.puzzle.author, copyright: copyrightLine) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - } - } -} - -private struct PuzzleTitle: View { - let title: String - let subtitle: String? - let isEngagementLive: Bool - @State private var showsEngagementIcon = false - - var body: some View { - VStack(spacing: 2) { - Text(title) - .font(.headline) - .lineLimit(2) - .overlay(alignment: .trailing) { - engagementIcon - .offset(x: 28) - .opacity(showsEngagementIcon ? 1 : 0) - .accessibilityLabel("Engagement live") - .accessibilityHidden(!showsEngagementIcon) - } - if let subtitle { - Text(subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .padding(.horizontal) - .animation(.easeInOut(duration: 0.2), value: showsEngagementIcon) - .onAppear { - showsEngagementIcon = isEngagementLive - } - .onChange(of: isEngagementLive) { _, isLive in - withAnimation(.easeInOut(duration: 0.2)) { - showsEngagementIcon = isLive - } - } - } - - private var engagementIcon: some View { - Image(systemName: "bolt.circle") - .font(.headline) - .foregroundStyle(.green) - .symbolRenderingMode(.monochrome) - } -} - -private struct PuzzleCredits: View { - let author: String? - let copyright: String? - - var body: some View { - VStack(spacing: 2) { - if let author, !author.isEmpty { - Text("By \(author)") - .font(.subheadline) - .lineLimit(2) - } - if let copyright { - Text(copyright) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(2) - } - } - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .padding(.horizontal) - } -} - -private struct ClueKey: Hashable { - let direction: Puzzle.Direction - let number: Int -} - -private struct ReplayClueTarget { - let position: GridPosition - let direction: Puzzle.Direction? -} - -private struct ClueBarSlot: View { - @Bindable var session: PlayerSession - let replayFrame: ReplayFrame? - - private var replayClueTarget: ReplayClueTarget? { - guard let cursor = replayFrame?.cursor else { return nil } - return ReplayClueTarget(position: cursor, direction: replayFrame?.cursorDirection) - } - - var body: some View { - ZStack(alignment: .bottom) { - ClueBarReservation() - - ClueBar(session: session, replayClueTarget: replayClueTarget) - } - } -} - -private struct ClueBarReservation: View { - var body: some View { - ClueBarContent( - label: "99 Across", - clueText: "Clue reservation", - reservesClueSpace: true - ) - .opacity(0) - .accessibilityHidden(true) - .allowsHitTesting(false) - } -} - -private struct ClueBarContent: View { - let label: String - let clueText: String - var reservesClueSpace = false - var currentKey: ClueKey? - var slideEdge: Edge = .trailing - var onPrevious: (() -> Void)? - var onNext: (() -> Void)? - var onClueTap: (() -> Void)? - var onLabelTap: (() -> Void)? - - var body: some View { - HStack(alignment: .clueCenter, spacing: 8) { - ClueBarIcon(systemName: "chevron.left", action: onPrevious) - - VStack(alignment: .leading, spacing: 4) { - Text(label) - .font(.caption) - .textCase(.uppercase) - .foregroundStyle(.secondary) - .contentShape(Rectangle()) - .highPriorityGesture( - TapGesture() - .onEnded { - onLabelTap?() - } - ) - ZStack(alignment: .leading) { - clueTextView - } - .alignmentGuide(.clueCenter) { d in d[VerticalAlignment.center] } - .frame(maxWidth: .infinity, alignment: .leading) - .clipped() - } - .contentShape(Rectangle()) - .onTapGesture { - onClueTap?() - } - - ClueBarIcon(systemName: "chevron.right", action: onNext) - } - .padding(.horizontal, 8) - .padding(.top, 12) - .padding(.bottom, 6) - } - - @ViewBuilder - private var clueTextView: some View { - baseClueText - .id(currentKey) - .transition(.asymmetric( - insertion: .move(edge: slideEdge), - removal: .move(edge: slideEdge == .trailing ? .leading : .trailing) - )) - } - - private var baseClueText: some View { - Text(clueText) - .font(.headline) - .lineLimit(2, reservesSpace: reservesClueSpace) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -private struct ClueBarIcon: View { - let systemName: String - var action: (() -> Void)? - - var body: some View { - if let action { - Button(action: action) { - icon - } - .buttonStyle(.plain) - } else { - icon - } - } - - private var icon: some View { - Image(systemName: systemName) - .font(.title3.weight(.semibold)) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - } -} - -private struct ClueBar: View { - @Bindable var session: PlayerSession - let replayClueTarget: ReplayClueTarget? - @Environment(PlayerPreferences.self) private var preferences - @Environment(\.colorScheme) private var colorScheme - @State private var slideEdge: Edge = .trailing - @State private var isShowingClueList = false - - private var backgroundColor: Color { - preferences.color.clueBarFill(dark: colorScheme == .dark) - } - - var body: some View { - let display = replayClueDisplay ?? liveClueDisplay - let isShowingReplayClue = replayClueDisplay != nil - - ClueBarContent( - label: label(for: display.clue, direction: display.direction), - clueText: display.clue?.text ?? "—", - currentKey: display.currentKey, - slideEdge: slideEdge, - onPrevious: isShowingReplayClue ? nil : { - slideEdge = .leading - session.goToPreviousClue() - }, - onNext: isShowingReplayClue ? nil : { - slideEdge = .trailing - session.goToNextClue() - }, - onClueTap: isShowingReplayClue ? nil : { - isShowingClueList = true - }, - onLabelTap: isShowingReplayClue ? nil : { - session.toggleDirection() - } - ) - .background(backgroundColor) - .animation( - isShowingReplayClue ? nil : .smooth(duration: 0.22), - value: display.currentKey - ) - .sheet(isPresented: $isShowingClueList) { - ClueList(session: session) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } - } - - private var liveClueDisplay: ClueDisplay { - let clue = session.currentClue() - return ClueDisplay(clue: clue, direction: session.direction) - } - - private var replayClueDisplay: ClueDisplay? { - guard let replayClueTarget else { return nil } - let position = replayClueTarget.position - guard let direction = replayClueTarget.direction else { return nil } - return ClueDisplay( - clue: session.puzzle.clue(atRow: position.row, col: position.col, direction: direction), - direction: direction - ) - } - - private struct ClueDisplay { - let clue: Puzzle.Clue? - let direction: Puzzle.Direction - - var currentKey: ClueKey? { - clue.map { ClueKey(direction: direction, number: $0.number) } - } - } - - private func label(for clue: Puzzle.Clue?, direction: Puzzle.Direction) -> String { - let direction = direction == .across ? "Across" : "Down" - if let clue { - return "\(clue.number) \(direction)" - } - return direction - } -} - -private struct RebusModal: View { - let text: String - - var body: some View { - Text(text.isEmpty ? " " : text) - .font(.system(size: 32, weight: .semibold, design: .rounded)) - .foregroundStyle(.primary) - .frame(maxWidth: .infinity, minHeight: 56) - .padding(.horizontal, 16) - .background(Color(.systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .padding(20) - .frame(maxWidth: .infinity) - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - } -} - -private struct ControlsView<Content: View>: View { - let height: CGFloat - @ViewBuilder var content: () -> Content - - var body: some View { - VStack(spacing: 0) { - content() - .frame(height: height) - Color(.systemGroupedBackground) - } - .background(Color(.systemGroupedBackground)) - .ignoresSafeArea(edges: .bottom) - } -} - -private extension VerticalAlignment { - enum ClueCenterID: AlignmentID { - static func defaultValue(in d: ViewDimensions) -> CGFloat { d[VerticalAlignment.center] } - } - static let clueCenter = VerticalAlignment(ClueCenterID.self) -} - -/// Lays subviews left-to-right, wrapping onto a new line when the next -/// subview would overflow the proposed width. Reports the wrapped -/// height for that width so a surrounding `ViewThatFits` can choose -/// between a centred (fits) and a scrolling (overflows) presentation. -private struct FlowLayout: Layout { - var spacing: CGFloat = 18 - var lineSpacing: CGFloat = 8 - - private struct Row { - var indices: [Int] = [] - var width: CGFloat = 0 - var height: CGFloat = 0 - } - - private func rows(_ subviews: Subviews, maxWidth: CGFloat) -> [Row] { - var rows: [Row] = [] - var current = Row() - for index in subviews.indices { - let size = subviews[index].sizeThatFits(.unspecified) - let needed = current.indices.isEmpty - ? size.width - : current.width + spacing + size.width - if !current.indices.isEmpty, needed > maxWidth { - rows.append(current) - current = Row(indices: [index], width: size.width, height: size.height) - } else { - if !current.indices.isEmpty { current.width += spacing } - current.indices.append(index) - current.width += size.width - current.height = max(current.height, size.height) - } - } - if !current.indices.isEmpty { rows.append(current) } - return rows - } - - func sizeThatFits( - proposal: ProposedViewSize, - subviews: Subviews, - cache: inout () - ) -> CGSize { - let rows = rows(subviews, maxWidth: proposal.width ?? .infinity) - let height = rows.reduce(0) { $0 + $1.height } - + lineSpacing * CGFloat(max(0, rows.count - 1)) - let widest = rows.map(\.width).max() ?? 0 - return CGSize(width: proposal.width ?? widest, height: height) - } - - func placeSubviews( - in bounds: CGRect, - proposal: ProposedViewSize, - subviews: Subviews, - cache: inout () - ) { - let rows = rows(subviews, maxWidth: bounds.width) - var y = bounds.minY - for row in rows { - // Centre each row within the available width so a short - // strip (e.g. a single player) sits in the middle. - var x = bounds.minX + max(0, (bounds.width - row.width) / 2) - for index in row.indices { - let size = subviews[index].sizeThatFits(.unspecified) - subviews[index].place( - at: CGPoint(x: x, y: y), - anchor: .topLeading, - proposal: ProposedViewSize(size) - ) - x += size.width + spacing - } - y += row.height + lineSpacing - } - } -} - -private struct WeightedVStack: Layout { - let weights: [CGFloat] - - func sizeThatFits( - proposal: ProposedViewSize, - subviews: Subviews, - cache: inout () - ) -> CGSize { - CGSize( - width: proposal.width ?? 0, - height: proposal.height ?? 0 - ) - } - - func placeSubviews( - in bounds: CGRect, - proposal: ProposedViewSize, - subviews: Subviews, - cache: inout () - ) { - let totalWeight = weights.reduce(0, +) - guard totalWeight > 0 else { return } - - var y = bounds.minY - for (index, subview) in subviews.enumerated() { - let weight = index < weights.count ? weights[index] : 0 - let height = bounds.height * weight / totalWeight - subview.place( - at: CGPoint(x: bounds.minX, y: y), - anchor: .topLeading, - proposal: ProposedViewSize(width: bounds.width, height: height) - ) - y += height - } - } -} diff --git a/Crossmate/Views/AboutView.swift b/Crossmate/Views/Settings/AboutView.swift diff --git a/Crossmate/Views/DiagnosticsView.swift b/Crossmate/Views/Settings/DiagnosticsView.swift diff --git a/Crossmate/Views/RecordEditorView.swift b/Crossmate/Views/Settings/RecordEditorView.swift diff --git a/Crossmate/Views/SettingsView.swift b/Crossmate/Views/Settings/SettingsView.swift