commit 61e4e1cd6e9120d52011417586a63ebf797e17f3
parent ef0790e65db5180ca840025c080be31ec32b89c2
Author: Michael Camilleri <[email protected]>
Date: Wed, 8 Jul 2026 12:00:34 +0900
Replace closure environment actions with AppActions
Custom SwiftUI environment and focused values were carrying action
closures for sharing, friend management, database reset, and puzzle
reveals. SwiftUI cannot reliably compare those function values, so
unrelated environment propagation could make dependent views and the
puzzle command menu invalidate as if the actions had changed.
This commit moves the app-level actions behind the stable AppActions
object owned by AppServices, and updates the Game List, sharing,
friends, and Settings surfaces to call named methods from that object.
The puzzle reveal command now uses RevealConfirmation as stable,
view-owned state, so PuzzleActionTarget no longer stores a closure while
still routing keyboard-menu reveals through the same confirmation alert
as the toolbar.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
13 files changed, 148 insertions(+), 128 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -161,6 +161,7 @@
AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */; };
ADBEAD1C0139BCF864CA8A1D /* PuzzleCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89528AF69DE06C61FA8E91A1 /* PuzzleCommands.swift */; };
AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */; };
+ AE92B185FB52032AEB9421A1 /* AppActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD36DEDB3D997AB47146EA9 /* AppActions.swift */; };
AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB851649DE78AAAC5A928C52 /* Square.swift */; };
B00743DAF8F46F14CE13E909 /* FriendsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 298A9C54A1CC753E860E174E /* FriendsView.swift */; };
B0170C8927EDD2E43F849204 /* TimeLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFFB5B2EFBB62B7021AC2FC2 /* TimeLog.swift */; };
@@ -455,6 +456,7 @@
D243575E32A8663B1AAF492A /* PeerChangeLedgerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerChangeLedgerTests.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; };
+ DAD36DEDB3D997AB47146EA9 /* AppActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppActions.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>"; };
@@ -838,6 +840,7 @@
children = (
03D59EA74A4BA084AD97478D /* AccountPushCoordinator.swift */,
1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */,
+ DAD36DEDB3D997AB47146EA9 /* AppActions.swift */,
CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */,
2D5B1E8E12B86DF6CA478F65 /* BadgeCoordinator.swift */,
98E68FD021EF8ED50BC706EE /* CloudContainer.swift */,
@@ -1089,6 +1092,7 @@
EA0AA522F6C383034C4572F4 /* AccountPushCoordinator.swift in Sources */,
A87E5E615559B6461B1C3F94 /* AnnouncementBanner.swift in Sources */,
5EFCD28B3B682DCCF38068D6 /* AnnouncementCenter.swift in Sources */,
+ AE92B185FB52032AEB9421A1 /* AppActions.swift in Sources */,
78802AFDF6273231781CC0DC /* AppServices.swift in Sources */,
A65F99414F8CF6704567BB07 /* Archive.swift in Sources */,
197DDF45C36B9570BB9AE4B5 /* AuthorIdentity.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -54,58 +54,7 @@ struct CrossmateApp: App {
.environment(\.engagementStatus, services.engagementStatus)
.environment(services.nytAuth)
.environment(\.nytPuzzleFetcher, services.nytFetcher)
- .environment(\.resetDatabase, {
- do {
- try await services.cloudService.resetAllData()
- } catch {
- services.announcements.post(Announcement(
- id: "reset-database-error",
- scope: .global,
- severity: .error,
- title: "Resetting Failed",
- body: error.localizedDescription,
- dismissal: .manual
- ))
- }
- })
- .environment(\.inviteFriend, { gameID, friendAuthorID in
- try await services.invites.inviteFriend(gameID: gameID, friendAuthorID: friendAuthorID)
- })
- .environment(\.acceptInvite, { shareURL, pingRecordName in
- _ = try await services.invites.acceptInvite(
- shareURL: shareURL,
- pingRecordName: pingRecordName
- )
- })
- .environment(\.declineInvite, { gameID in
- try await services.invites.declineInvite(gameID: gameID)
- })
- .environment(\.blockFriend, { friendAuthorID in
- await services.invites.blockFriend(authorID: friendAuthorID)
- })
- .environment(\.unblockFriend, { friendAuthorID in
- await services.invites.unblockFriend(authorID: friendAuthorID)
- })
- .environment(\.renameFriend, { friendAuthorID, nickname in
- do {
- try await services.friendController.setNickname(
- friendAuthorID: friendAuthorID,
- nickname: nickname
- )
- } catch {
- services.announcements.post(Announcement(
- id: "rename-friend-error-\(friendAuthorID)",
- scope: .global,
- severity: .error,
- title: "Renaming Failed",
- body: error.localizedDescription,
- dismissal: .manual
- ))
- }
- })
- .environment(\.sendResignPings, { gameID in
- await services.sessions.sendCompletionPings(gameID: gameID, resigned: true)
- })
+ .environment(\.appActions, services.appActions)
}
}
diff --git a/Crossmate/Services/AppActions.swift b/Crossmate/Services/AppActions.swift
@@ -0,0 +1,75 @@
+import Foundation
+import SwiftUI
+
+@MainActor
+final class AppActions {
+ private unowned let services: AppServices
+
+ init(services: AppServices) {
+ self.services = services
+ }
+
+ func resetDatabase() async {
+ do {
+ try await services.cloudService.resetAllData()
+ } catch {
+ services.announcements.post(Announcement(
+ id: "reset-database-error",
+ scope: .global,
+ severity: .error,
+ title: "Resetting Failed",
+ body: error.localizedDescription,
+ dismissal: .manual
+ ))
+ }
+ }
+
+ func inviteFriend(gameID: UUID, friendAuthorID: String) async throws {
+ try await services.invites.inviteFriend(gameID: gameID, friendAuthorID: friendAuthorID)
+ }
+
+ func acceptInvite(shareURL: String, pingRecordName: String) async throws {
+ _ = try await services.invites.acceptInvite(
+ shareURL: shareURL,
+ pingRecordName: pingRecordName
+ )
+ }
+
+ func declineInvite(gameID: UUID) async throws {
+ try await services.invites.declineInvite(gameID: gameID)
+ }
+
+ func blockFriend(authorID: String) async {
+ await services.invites.blockFriend(authorID: authorID)
+ }
+
+ func unblockFriend(authorID: String) async {
+ await services.invites.unblockFriend(authorID: authorID)
+ }
+
+ func renameFriend(authorID: String, nickname: String) async {
+ do {
+ try await services.friendController.setNickname(
+ friendAuthorID: authorID,
+ nickname: nickname
+ )
+ } catch {
+ services.announcements.post(Announcement(
+ id: "rename-friend-error-\(authorID)",
+ scope: .global,
+ severity: .error,
+ title: "Renaming Failed",
+ body: error.localizedDescription,
+ dismissal: .manual
+ ))
+ }
+ }
+
+ func sendResignPings(gameID: UUID) async {
+ await services.sessions.sendCompletionPings(gameID: gameID, resigned: true)
+ }
+}
+
+extension EnvironmentValues {
+ @Entry var appActions: AppActions? = nil
+}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -316,6 +316,7 @@ final class AppServices {
let importService: ImportService
let engagementHost: EngagementHost
let engagementStatus = EngagementStatus()
+ private(set) lazy var appActions = AppActions(services: self)
/// Live-channel lifecycle (room reconcile/mint, teardown/reconnect/
/// lease-expiry timers, inbound channel events); see `EngagementLifecycle`.
/// Lazy so its callbacks into the read-cursor and sync-start paths can
diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift
@@ -9,7 +9,7 @@ import UserNotifications
/// `InviteEntity` rows behind the library's "Invited" section, and friend
/// blocking. `AppServices` composes one instance and forwards the
/// `SyncEngine` ping callbacks into it; the accept/decline/block entry
-/// points are surfaced to the UI through environment closures.
+/// points are surfaced to the UI through `AppActions`.
@MainActor
final class InviteCoordinator {
enum InviteAcceptanceError: LocalizedError {
@@ -75,7 +75,7 @@ final class InviteCoordinator {
/// Re-invites an existing friend to a game: adds them as a participant on
/// the game's `CKShare` and writes an `.invite` Ping into the friend zone.
- /// Surfaced to the UI via the `\.inviteFriend` environment closure.
+ /// Surfaced to the UI via `AppActions`.
func inviteFriend(gameID: UUID, friendAuthorID: String) async throws {
guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else {
throw FriendController.FriendError.friendNotFound
@@ -323,7 +323,7 @@ final class InviteCoordinator {
/// the existing share-accept path, then drops the local `InviteEntity`
/// (the game now represents it). If CloudKit says the share URL no longer
/// exists, the durable invite row is stale, so it is removed as well.
- /// Surfaced via `\.acceptInvite`.
+ /// Surfaced via `AppActions`.
@discardableResult
func acceptInvite(shareURL: String, pingRecordName: String) async throws -> CloudService.AcceptOutcome {
guard let url = URL(string: shareURL) else {
@@ -417,7 +417,7 @@ final class InviteCoordinator {
/// resurrecting locally if CloudKit deletion is delayed), sends a `.decline`
/// Ping back to each inviter so they free our seat and see a banner, consumes
/// the source invite Ping so sibling devices clear their rows, and refreshes
- /// the badge. Surfaced via `\.declineInvite`; the AppServices entry point
+ /// the badge. Surfaced via `AppActions`; the AppServices entry point
/// keeps invite mutation and the badge refresh in one place, mirroring
/// `acceptInvite`.
func declineInvite(gameID: UUID) async throws {
@@ -471,7 +471,7 @@ final class InviteCoordinator {
/// (server-enforced, reversible — no teardown), hides every game they
/// currently share with us (reversibly — the share/access is kept, the game
/// just leaves the list), and drops their pending invites. Games we *own*
- /// that they joined are untouched. Surfaced via `\.blockFriend`.
+ /// that they joined are untouched. Surfaced via `AppActions`.
func blockFriend(authorID: String) async {
do {
try await friendController.block(friendAuthorID: authorID)
@@ -504,7 +504,7 @@ final class InviteCoordinator {
/// Reverses `blockFriend`: upgrades the friend back to read-write on the
/// inbox we own, so they can reach us again with no re-pairing, and reveals
- /// the games hidden at block time. Surfaced via `\.unblockFriend`.
+ /// the games hidden at block time. Surfaced via `AppActions`.
func unblockFriend(authorID: String) async {
do {
try await friendController.unblock(friendAuthorID: authorID)
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -5,26 +5,6 @@ import SwiftUI
extension EnvironmentValues {
@Entry var syncEngine: SyncEngine? = nil
- @Entry var resetDatabase: (() async -> Void)? = nil
- /// `(gameID, friendAuthorID)` — re-invites an existing friend to a game.
- @Entry var inviteFriend: ((UUID, String) async throws -> Void)? = nil
- /// `(shareURL, pingRecordName)` — accepts a pending game invite.
- @Entry var acceptInvite: ((String, String) async throws -> Void)? = nil
- /// `(gameID)` — declines a pending game invite (tombstone + badge refresh).
- @Entry var declineInvite: ((UUID) async throws -> Void)? = nil
- /// `(friendAuthorID)` — blocks a collaborator: downgrades them to read-only
- /// on the inbox we own (server-enforced, reversible) and leaves their
- /// shared games.
- @Entry var blockFriend: ((String) async -> Void)? = nil
- /// `(friendAuthorID)` — reverses `blockFriend`: upgrades the friend back to
- /// read-write on our inbox, restoring the channel with no re-pairing.
- @Entry var unblockFriend: ((String) async -> Void)? = nil
- /// `(friendAuthorID, nickname)` — sets the user's private nickname for a
- /// friend; an empty nickname clears it back to the friend's own name.
- @Entry var renameFriend: ((String, String) async -> Void)? = nil
- /// `(gameID)` — fires the completion APN after a game is resigned from
- /// the library (the in-puzzle path uses `onResign` directly).
- @Entry var sendResignPings: ((UUID) async -> Void)? = nil
}
extension Notification.Name {
diff --git a/Crossmate/Views/Friends/FriendPickerView.swift b/Crossmate/Views/Friends/FriendPickerView.swift
@@ -8,7 +8,7 @@ struct FriendPickerView: View {
let gameID: UUID
let shareController: ShareController
- @Environment(\.inviteFriend) private var inviteFriend
+ @Environment(\.appActions) private var appActions
@Environment(\.dismiss) private var dismiss
@FetchRequest(
@@ -107,12 +107,12 @@ struct FriendPickerView: View {
}
private func invite(_ authorID: String) async {
- guard !authorID.isEmpty, let inviteFriend else { return }
+ guard !authorID.isEmpty, let appActions else { return }
withAnimation(.snappy) { invitingAuthorID = authorID }
errorMessage = nil
defer { withAnimation(.snappy) { invitingAuthorID = nil } }
do {
- try await inviteFriend(gameID, authorID)
+ try await appActions.inviteFriend(gameID: gameID, friendAuthorID: authorID)
withAnimation(.snappy) {
_ = invitedAuthorIDs.insert(authorID)
isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1
diff --git a/Crossmate/Views/Friends/FriendsView.swift b/Crossmate/Views/Friends/FriendsView.swift
@@ -10,15 +10,13 @@ struct FriendNewGameTarget: Identifiable, Equatable {
/// Sheet listing the user's non-blocked friends, presented from
/// the game list. Friends are accumulated automatically the first time you
/// collaborate with someone (see `FriendController`); the actions are renaming
-/// (a private nickname via `\.renameFriend`) and blocking via `\.blockFriend`
-/// (a reversible, server-enforced read-only downgrade on the inbox we own).
-/// Blocked friends are intentionally hidden from this list; unblocking lives
-/// on Settings' Blocked Users screen (via `\.unblockFriend`).
+/// (a private nickname) and blocking (a reversible, server-enforced read-only
+/// downgrade on the inbox we own). Blocked friends are intentionally hidden
+/// from this list; unblocking lives on Settings' Blocked Users screen.
struct FriendsView: View {
var onStartGame: (FriendNewGameTarget) -> Void = { _ in }
- @Environment(\.blockFriend) private var blockFriend
- @Environment(\.renameFriend) private var renameFriend
+ @Environment(\.appActions) private var appActions
@Environment(\.dismiss) private var dismiss
@FetchRequest(
@@ -67,7 +65,7 @@ struct FriendsView: View {
Button("Rename") {
if let authorID = renameTarget?.authorID {
let nickname = renameText
- Task { await renameFriend?(authorID, nickname) }
+ Task { await appActions?.renameFriend(authorID: authorID, nickname: nickname) }
}
}
Button("Cancel", role: .cancel) {}
@@ -80,7 +78,7 @@ struct FriendsView: View {
)) {
Button("Block", role: .destructive) {
if let authorID = blockTarget?.authorID {
- Task { await blockFriend?(authorID) }
+ Task { await appActions?.blockFriend(authorID: authorID) }
}
}
Button("Cancel", role: .cancel) {}
diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift
@@ -39,11 +39,7 @@ struct GameListView: View {
// but the library and badge state should never surface them.
private var blockedFriends: FetchedResults<FriendEntity>
- @Environment(\.acceptInvite) private var acceptInvite
- @Environment(\.declineInvite) private var declineInvite
- @Environment(\.blockFriend) private var blockFriend
- @Environment(\.inviteFriend) private var inviteFriend
- @Environment(\.sendResignPings) private var sendResignPings
+ @Environment(\.appActions) private var appActions
@Environment(PlayerPreferences.self) private var preferences
@Environment(AnnouncementCenter.self) private var announcements
@Environment(EventLog.self) private var eventLog
@@ -201,7 +197,7 @@ struct GameListView: View {
do {
try store.resignGame(id: target.id)
let id = target.id
- Task { await sendResignPings?(id) }
+ Task { await appActions?.sendResignPings(gameID: id) }
} catch {
announcements.post(Announcement(
id: Self.destructiveActionErrorID,
@@ -271,7 +267,7 @@ struct GameListView: View {
)) {
Button("Block", role: .destructive) {
if let target = blockTarget, let authorID = target.inviterAuthorID {
- Task { await blockFriend?(authorID) }
+ Task { await appActions?.blockFriend(authorID: authorID) }
}
}
Button("Cancel", role: .cancel) {}
@@ -352,10 +348,10 @@ struct GameListView: View {
}
private func inviteNewGame(_ gameID: UUID, to target: FriendNewGameTarget) async {
- guard let inviteFriend else { return }
+ guard let appActions else { return }
announcements.dismiss(id: Self.newGameInviteErrorID)
do {
- try await inviteFriend(gameID, target.authorID)
+ try await appActions.inviteFriend(gameID: gameID, friendAuthorID: target.authorID)
} catch {
eventLog.note(
"new game friend invite failed game=\(gameID.uuidString) friend=\(target.authorID): \(error)",
@@ -811,8 +807,8 @@ struct GameListView: View {
do {
if let onAcceptInvite {
try await onAcceptInvite(url, ping, shape)
- } else if let acceptInvite {
- try await acceptInvite(url, ping)
+ } else if let appActions {
+ try await appActions.acceptInvite(shareURL: url, pingRecordName: ping)
}
} catch {
announcements.post(Announcement(
@@ -841,9 +837,9 @@ struct GameListView: View {
private static let destructiveActionErrorID = "game-list-destructive-action-error"
private func decline(_ invite: InviteEntity) async {
- guard let declineInvite, let gameID = invite.gameID else { return }
+ guard let appActions, let gameID = invite.gameID else { return }
do {
- try await declineInvite(gameID)
+ try await appActions.declineInvite(gameID: gameID)
} catch {
announcements.post(Announcement(
id: Self.destructiveActionErrorID,
diff --git a/Crossmate/Views/GameList/GameShareItem.swift b/Crossmate/Views/GameList/GameShareItem.swift
@@ -7,7 +7,7 @@ struct GameShareSheet: View {
let title: String
let shareController: ShareController
- @Environment(\.inviteFriend) private var inviteFriend
+ @Environment(\.appActions) private var appActions
@Environment(\.dismiss) private var dismiss
@Environment(\.syncEngine) private var syncEngine
@Environment(SyncMonitor.self) private var syncMonitor
@@ -328,13 +328,13 @@ struct GameShareSheet: View {
}
private func invite(_ authorID: String) async {
- guard !authorID.isEmpty, let inviteFriend else { return }
+ guard !authorID.isEmpty, let appActions else { return }
withAnimation(.snappy) { invitingAuthorID = authorID }
shareError = nil
defer { withAnimation(.snappy) { invitingAuthorID = nil } }
do {
- try await inviteFriend(gameID, authorID)
+ try await appActions.inviteFriend(gameID: gameID, friendAuthorID: authorID)
withAnimation(.snappy) {
_ = invitedAuthorIDs.insert(authorID)
isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1
diff --git a/Crossmate/Views/Puzzle/PuzzleCommands.swift b/Crossmate/Views/Puzzle/PuzzleCommands.swift
@@ -1,5 +1,17 @@
import SwiftUI
+@MainActor
+@Observable
+final class RevealConfirmation {
+ var isConfirming = false
+ var pendingScope: RevealScope = .square
+
+ func request(_ scope: RevealScope) {
+ pendingScope = scope
+ isConfirming = true
+ }
+}
+
/// The active puzzle's command surface, published into the focused scene by
/// `PuzzleView` and read by `PuzzleCommands`. Carrying it through a focused
/// value is what lets the app-level menu (and the hold-⌘ discoverability
@@ -7,24 +19,31 @@ import SwiftUI
/// disable itself when none is.
struct PuzzleActionTarget: Equatable {
let session: PlayerSession
+ let revealConfirmation: RevealConfirmation
/// False when the puzzle is solved or input is blocked (e.g. access
/// revoked) — the same gate the toolbar's Entry/Hints menus use.
let isEnabled: Bool
- /// Routes a reveal through `PuzzleView`'s confirmation alert rather than
- /// revealing immediately, matching the toolbar buttons.
- let requestReveal: (RevealScope) -> Void
// Rebus owns hardware input while active, so undo/redo step aside then —
// mirroring the old hardware-key behaviour before it moved into the menu.
@MainActor var canUndo: Bool { isEnabled && !session.isRebusActive && session.canUndo }
@MainActor var canRedo: Bool { isEnabled && !session.isRebusActive && session.canRedo }
- // PuzzleView republishes this on every render and the closure identity
- // changes each time; keying equality on the session and enabled flag lets
- // SwiftUI dedupe so the menu isn't rebuilt on every render. Rebuilding it on
- // each pass during a puzzle load reentrantly deadlocks UIKit's menu builder.
+ /// Routes a reveal through `PuzzleView`'s confirmation alert rather than
+ /// revealing immediately, matching the toolbar buttons.
+ @MainActor
+ func requestReveal(_ scope: RevealScope) {
+ revealConfirmation.request(scope)
+ }
+
+ // PuzzleView republishes this on every render; keying equality on stable
+ // references and the enabled flag lets SwiftUI dedupe so the menu isn't
+ // rebuilt on every render. Rebuilding it on each pass during a puzzle load
+ // reentrantly deadlocks UIKit's menu builder.
static func == (lhs: PuzzleActionTarget, rhs: PuzzleActionTarget) -> Bool {
- lhs.session === rhs.session && lhs.isEnabled == rhs.isEnabled
+ lhs.session === rhs.session
+ && lhs.revealConfirmation === rhs.revealConfirmation
+ && lhs.isEnabled == rhs.isEnabled
}
}
diff --git a/Crossmate/Views/Puzzle/PuzzleView.swift b/Crossmate/Views/Puzzle/PuzzleView.swift
@@ -57,8 +57,7 @@ struct PuzzleView: View {
@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 revealConfirmation = RevealConfirmation()
@State private var isConfirmingClear = false
@State private var leaveError: String?
@State private var destructiveActionError: String?
@@ -134,6 +133,8 @@ struct PuzzleView: View {
}
var body: some View {
+ @Bindable var revealConfirmation = revealConfirmation
+
Group {
switch effectivePadLayout {
case .landscape:
@@ -180,8 +181,8 @@ struct PuzzleView: View {
isConfirmingResign: $isConfirmingResign,
isConfirmingDelete: $isConfirmingDelete,
isConfirmingLeave: $isConfirmingLeave,
- isConfirmingReveal: $isConfirmingReveal,
- pendingRevealScope: $pendingRevealScope,
+ isConfirmingReveal: $revealConfirmation.isConfirming,
+ pendingRevealScope: $revealConfirmation.pendingScope,
isConfirmingClear: $isConfirmingClear,
isShowingShareSheet: $isShowingShareSheet
))
@@ -203,8 +204,8 @@ struct PuzzleView: View {
isConfirmingResign: $isConfirmingResign,
isConfirmingDelete: $isConfirmingDelete,
isConfirmingLeave: $isConfirmingLeave,
- isConfirmingReveal: $isConfirmingReveal,
- pendingRevealScope: $pendingRevealScope,
+ isConfirmingReveal: $revealConfirmation.isConfirming,
+ pendingRevealScope: $revealConfirmation.pendingScope,
isConfirmingClear: $isConfirmingClear,
leaveError: $leaveError,
destructiveActionError: $destructiveActionError,
@@ -218,11 +219,8 @@ struct PuzzleView: View {
// confirmation alert the toolbar uses.
.focusedSceneValue(\.puzzleActions, PuzzleActionTarget(
session: session,
- isEnabled: !isSolved && !isInputBlocked,
- requestReveal: { scope in
- pendingRevealScope = scope
- isConfirmingReveal = true
- }
+ revealConfirmation: revealConfirmation,
+ isEnabled: !isSolved && !isInputBlocked
))
.frame(
width: scenePhase == .active || lastActiveSize == .zero ? nil : lastActiveSize.width,
diff --git a/Crossmate/Views/Settings/SettingsView.swift b/Crossmate/Views/Settings/SettingsView.swift
@@ -4,7 +4,7 @@ struct SettingsView: View {
@Environment(NYTAuthService.self) private var nytAuth
@Environment(PlayerPreferences.self) private var preferences
@Environment(\.dismiss) private var dismiss
- @Environment(\.resetDatabase) private var resetDatabase
+ @Environment(\.appActions) private var appActions
@AppStorage("debugMode") private var debugMode = false
@State private var showingNYTLogin = false
@@ -100,7 +100,7 @@ struct SettingsView: View {
isPresented: $showResetConfirmation
) {
Button("Delete All Puzzles", role: .destructive) {
- Task { await resetDatabase?() }
+ Task { await appActions?.resetDatabase() }
}
Button("Cancel", role: .cancel) {}
} message: {
@@ -213,7 +213,7 @@ struct SettingsView: View {
}
private struct BlockedUsersView: View {
- @Environment(\.unblockFriend) private var unblockFriend
+ @Environment(\.appActions) private var appActions
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \FriendEntity.createdAt, ascending: true)],
@@ -247,7 +247,7 @@ private struct BlockedUsersView: View {
)) {
Button("Unblock") {
guard let authorID = unblockTarget?.authorID, !authorID.isEmpty else { return }
- Task { await unblockFriend?(authorID) }
+ Task { await appActions?.unblockFriend(authorID: authorID) }
}
Button("Cancel", role: .cancel) {}
} message: {