crossmate

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

commit 0d044c222ebf0c61ccad7b4d1a9bb92228db9609
parent ec59ffe353eeee1ed7f51fe49e594b30797dd41f
Author: Michael Camilleri <[email protected]>
Date:   Sun,  7 Jun 2026 14:53:47 +0900

Restyle announcement banners

This commit replaces the severity-tinted announcement box with a more
compact banner treatment. Announcements now use a neutral material
background, a narrow severity rail, a smaller leading symbol,
left-aligned text, and a close affordance only for manual-dismiss
announcements. Puzzle headers keep the banner aligned to the bottom of
the fixed header area so transient updates read in context without
stretching to fill the whole header.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 4++--
MCrossmate/Services/AnnouncementCenter.swift | 4+++-
MCrossmate/Services/AppServices.swift | 5+++--
MCrossmate/Views/AnnouncementBanner.swift | 103++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
MCrossmate/Views/GameListView.swift | 8++++----
MCrossmate/Views/PuzzleView.swift | 2+-
6 files changed, 70 insertions(+), 56 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -39,7 +39,7 @@ struct CrossmateApp: App { id: "reset-database-error", scope: .global, severity: .error, - title: "Couldn't Reset Database", + title: "Resetting Failed", body: error.localizedDescription, dismissal: .manual )) @@ -483,7 +483,7 @@ private struct PuzzleDisplayView: View { id: "mark-completed-error-\(gameID.uuidString)", scope: .game(gameID), severity: .error, - title: "Couldn't Save Completion", + title: "Saving Failed", body: error.localizedDescription, dismissal: .manual )) diff --git a/Crossmate/Services/AnnouncementCenter.swift b/Crossmate/Services/AnnouncementCenter.swift @@ -3,7 +3,7 @@ import Observation /// One-shot status message surfaced in a banner area — the puzzle header /// when scoped to a game, the game list when global. Designed to host both -/// info-class summaries (e.g. "Alice added 4 letters while you were away") +/// info-class summaries (e.g. "Alice added 4 letters") /// and error-class failures (e.g. "Couldn't accept invite") that previously /// went through modal alerts. struct Announcement: Identifiable, Equatable, Sendable { @@ -84,6 +84,7 @@ extension Announcement { id: "access-revoked-\(gameID.uuidString)", scope: .game(gameID), severity: .error, + title: "Puzzle Not Shared", body: "This puzzle is no longer shared with you.", dismissal: .sticky, blocksInput: true @@ -102,6 +103,7 @@ extension Announcement { id: "game-removed-\(gameID.uuidString)", scope: .game(gameID), severity: .error, + title: "Puzzle Removed", body: "This puzzle was removed.", dismissal: .sticky, blocksInput: true diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -580,7 +580,7 @@ final class AppServices { id: "remove-pending-invite-error-\(gameID.uuidString)", scope: .global, severity: .error, - title: "Couldn't Clear Invite", + title: "Clearing Failed", body: error.localizedDescription, dismissal: .manual )) @@ -2580,7 +2580,7 @@ final class AppServices { id: "block-friend-error-\(authorID)", scope: .global, severity: .error, - title: "Couldn't Block Collaborator", + title: "Blocking Failed", body: error.localizedDescription, dismissal: .manual )) @@ -2820,6 +2820,7 @@ final class AppServices { id: "session-summary-\(gameID.uuidString)", scope: .game(gameID), severity: .info, + title: "Puzzle Updated", body: body, dismissal: .transient(after: 6) )) diff --git a/Crossmate/Views/AnnouncementBanner.swift b/Crossmate/Views/AnnouncementBanner.swift @@ -1,39 +1,64 @@ import SwiftUI -/// Banner-style surface for an `Announcement`. Renders a severity icon and -/// body text over a severity-tinted background, taps to dismiss when -/// `.manual`, and is intended to live in the puzzle header (and eventually -/// the game list) behind a `.transition(.move(edge: .top))` driven by the -/// parent. +/// Banner-style surface for an `Announcement`. Renders a severity rail, icon, +/// and body text over a neutral material background. Manual announcements get +/// a close affordance; transient and sticky announcements do not. struct AnnouncementBanner: View { let announcement: Announcement let onDismiss: (() -> Void)? var body: some View { - let tint = backgroundTint(for: announcement.severity) - HStack(spacing: 10) { - Image(systemName: iconName(for: announcement.severity)) - // `.title` (~28pt) against the `.subheadline` (~15pt) body - // text — roughly 1.9× — so the severity reads at a glance. - .font(.title.weight(.semibold)) - .foregroundStyle(iconTint(for: announcement.severity)) + let severityColor = tint(for: announcement.severity) + HStack(spacing: 0) { + Rectangle() + .fill(severityColor) + .frame(width: 4) .accessibilityHidden(true) - VStack(spacing: 2) { - if let title = announcement.title, !title.isEmpty { - Text(title) - .font(.subheadline.weight(.semibold)) - .lineLimit(1) + + HStack(alignment: .center, spacing: 8) { + Image(systemName: iconName(for: announcement.severity)) + .font(.footnote.weight(.semibold)) + .foregroundStyle(severityColor) + .frame(width: 24) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 3) { + if let title = announcement.title, !title.isEmpty { + Text(title) + .font(.footnote.weight(.semibold)) + .lineLimit(1) + } + Text(announcement.body) + .font(.footnote) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + + if case .manual = announcement.dismissal { + Button { + onDismiss?() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.callout) + .symbolRenderingMode(.hierarchical) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .accessibilityLabel("Dismiss announcement") } - Text(announcement.body) - .font(.subheadline) - .multilineTextAlignment(.center) - .lineLimit(3) } + .padding(.horizontal, 10) + .padding(.vertical, 6) + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) } - .frame(maxWidth: .infinity, alignment: .center) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(tint, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .contentShape(Rectangle()) .onTapGesture { // `.transient` self-dismisses; `.sticky` requires programmatic @@ -44,34 +69,20 @@ struct AnnouncementBanner: View { .accessibilityElement(children: .combine) } - private func backgroundTint(for severity: Announcement.Severity) -> Color { - switch severity { - case .info: return Color.blue.opacity(0.18) - case .warning: return Color.orange.opacity(0.18) - case .error: return Color.red.opacity(0.18) - } - } - - /// SF Symbol that leads the banner text, one per severity — an attention - /// cue that reads at a glance even before the text does. + /// SF Symbol that leads the banner text, one per severity. private func iconName(for severity: Announcement.Severity) -> String { switch severity { - case .info: return "bell.circle" + case .info: return "bell" case .warning: return "exclamationmark.triangle" case .error: return "xmark.octagon" } } - /// Foreground colour for the severity icon. Mixed toward `.primary` so the - /// icon reads as the "ink" version of `backgroundTint` — darker than the - /// background hue in light mode, lighter in dark mode — giving real tonal - /// contrast instead of saturated-on-tinted same-hue mush. - private func iconTint(for severity: Announcement.Severity) -> Color { - let base: Color = switch severity { - case .info: .blue - case .warning: .orange - case .error: .red + private func tint(for severity: Announcement.Severity) -> Color { + switch severity { + case .info: return .blue + case .warning: return .orange + case .error: return .red } - return base.mix(with: .primary, by: 0.4) } } diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift @@ -112,7 +112,7 @@ struct GameListView: View { id: Self.destructiveActionErrorID, scope: .global, severity: .error, - title: "Couldn't Resign Puzzle", + title: "Resigning Failed", body: error.localizedDescription, dismissal: .manual )) @@ -153,7 +153,7 @@ struct GameListView: View { id: Self.destructiveActionErrorID, scope: .global, severity: .error, - title: "Couldn't Delete Puzzle", + title: "Deleting Failed", body: error.localizedDescription, dismissal: .manual )) @@ -528,7 +528,7 @@ struct GameListView: View { id: Self.inviteErrorID, scope: .global, severity: .error, - title: "Couldn't Accept Invite", + title: "Accepting Failed", body: error.localizedDescription, dismissal: .manual )) @@ -552,7 +552,7 @@ struct GameListView: View { id: Self.destructiveActionErrorID, scope: .global, severity: .error, - title: "Couldn't Decline Invite", + title: "Declining Failed", body: error.localizedDescription, dismissal: .manual )) diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -1241,7 +1241,7 @@ private struct PuzzleHeader: View { announcements.dismiss(id: announcement.id) } .padding(.horizontal, 12) - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) .transition(.move(edge: .bottom).combined(with: .opacity)) } else { headerPages