crossmate

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

commit 347d9f44d6b36f38766b16a0ecee15baa618d2b7
parent fe2d271340a3bee69cf373e436a5dfddd3a63ab4
Author: Michael Camilleri <[email protected]>
Date:   Thu, 21 May 2026 10:41:43 +0900

Move the invite-accept error onto the announcement banner

The game list now carries an AnnouncementBanner above its List, bound to
AnnouncementCenter.currentGlobal() and sliding in from the top. The 'Couldn't
Accept Invite' modal alert is retired: accept(_:) posts a global .error
announcement with .manual dismissal instead, so the failure sits as a banner
until the user taps it away rather than interrupting with a dialog. A
single-slot id means a fresh failure replaces the prior one rather than
stacking.

This does not affect other game-list alerts. These are confirmation dialogs
that need a user decision and stay as modals.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate/Views/GameListView.swift | 40++++++++++++++++++++++++++--------------
1 file changed, 26 insertions(+), 14 deletions(-)

diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift @@ -33,7 +33,7 @@ struct GameListView: View { @Environment(\.acceptInvite) private var acceptInvite @Environment(\.blockFriend) private var blockFriend @Environment(\.sendResignPings) private var sendResignPings - @State private var inviteError: String? + @Environment(AnnouncementCenter.self) private var announcements @State private var acceptingInviteID: NSManagedObjectID? @State private var blockTarget: InviteEntity? @@ -50,7 +50,18 @@ struct GameListView: View { var body: some View { GeometryReader { geometry in - content(usesRoomierType: usesRoomierType(for: geometry.size)) + 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) @@ -133,16 +144,6 @@ struct GameListView: View { } } } - .alert("Couldn't Accept Invite", isPresented: .init( - get: { inviteError != nil }, - set: { if !$0 { inviteError = nil } } - )) { - Button("OK", role: .cancel) {} - } message: { - if let inviteError { - Text(inviteError) - } - } .alert("Block This Player?", isPresented: .init( get: { blockTarget != nil }, set: { if !$0 { blockTarget = nil } } @@ -284,15 +285,26 @@ struct GameListView: View { let ping = invite.pingRecordName else { return } acceptingInviteID = invite.objectID - inviteError = nil + announcements.dismiss(id: Self.inviteErrorID) defer { acceptingInviteID = nil } do { try await acceptInvite(url, ping) } catch { - inviteError = error.localizedDescription + announcements.post(Announcement( + id: Self.inviteErrorID, + scope: .global, + severity: .error, + title: "Couldn't Accept Invite", + 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" + private func decline(_ invite: InviteEntity) { invite.status = "declined" try? viewContext.save()