GameCardView.swift (14719B)
1 import SwiftUI 2 3 // MARK: - Card (regular width) 4 5 enum CardMetrics { 6 static let height: CGFloat = 110 7 static let cornerRadius: CGFloat = 12 8 } 9 10 /// Tappable card used in the iPad grid layout. The card handles plain taps, 11 /// while the participant swatch and overflow menu remain independent controls. 12 struct GameCardView: View { 13 let game: GameSummary 14 let shareController: ShareController 15 let usesRoomierType: Bool 16 var onResume: () -> Void = {} 17 var onLeave: () -> Void = {} 18 var onResign: () -> Void = {} 19 var onDelete: () -> Void = {} 20 @State private var isShowingShareSheet = false 21 22 var body: some View { 23 let showsUnreadBadge = game.hasUnreadOtherMoves 24 25 HStack(spacing: 12) { 26 GameListThumbnailView( 27 game: game, 28 showsUnreadBadge: showsUnreadBadge 29 ) 30 VStack(alignment: .leading, spacing: 2) { 31 HStack(spacing: 4) { 32 Text(game.title) 33 .font(.headline) 34 .lineLimit(1) 35 .minimumScaleFactor(0.9) 36 .truncationMode(.tail) 37 if game.isShared { 38 SharedGameSymbol() 39 } 40 } 41 GameMetadataView( 42 puzzleDate: game.puzzleDate, 43 publisher: game.publisher, 44 usesRoomierType: usesRoomierType 45 ) 46 if let date = game.updatedAt { 47 LastUpdatedView(date: date, usesRoomierType: usesRoomierType) 48 } 49 } 50 Spacer(minLength: 0) 51 // Reserve room for the overflow menu, which is layered as an 52 // overlay so its taps don't fall through to the card tap. 53 Color.clear.frame(width: 32, height: 32) 54 } 55 .padding(12) 56 .frame(maxWidth: .infinity) 57 .frame(height: CardMetrics.height) 58 .background( 59 Color(.secondarySystemGroupedBackground), 60 in: RoundedRectangle(cornerRadius: CardMetrics.cornerRadius) 61 ) 62 .contentShape(RoundedRectangle(cornerRadius: CardMetrics.cornerRadius)) 63 .onTapGesture(perform: onResume) 64 .overlay(alignment: .trailing) { 65 GameOverflowMenu( 66 game: game, 67 onShare: { isShowingShareSheet = true }, 68 onResume: onResume, 69 onLeave: onLeave, 70 onResign: onResign, 71 onDelete: onDelete 72 ) 73 .padding(.trailing, 12) 74 } 75 .gameSummaryAccessibility( 76 game: game, 77 onInvite: { isShowingShareSheet = true }, 78 onOpen: onResume, 79 onLeave: onLeave, 80 onResign: onResign, 81 onDelete: onDelete 82 ) 83 .sheet(isPresented: $isShowingShareSheet) { 84 GameShareSheet( 85 gameID: game.id, 86 title: game.title, 87 shareController: shareController 88 ) 89 } 90 } 91 } 92 93 private struct GameSummaryAccessibility: ViewModifier { 94 let game: GameSummary 95 let onInvite: () -> Void 96 let onOpen: () -> Void 97 let onLeave: () -> Void 98 let onResign: () -> Void 99 let onDelete: () -> Void 100 101 private var isCompleted: Bool { 102 game.completedAt != nil 103 } 104 105 private var shouldLeaveShare: Bool { 106 !isCompleted && !game.isOwned && game.isShared 107 } 108 109 func body(content: Content) -> some View { 110 content 111 .accessibilityElement(children: .ignore) 112 .accessibilityLabel(accessibilityLabel) 113 .accessibilityHint(isCompleted ? "Opens completed puzzle" : "Opens puzzle") 114 .accessibilityAddTraits(.isButton) 115 .accessibilityAction { 116 onOpen() 117 } 118 .accessibilityActions { 119 if !isCompleted, game.isOwned { 120 Button("Invite", action: onInvite) 121 } 122 Button(isCompleted ? "View" : "Resume", action: onOpen) 123 if !isCompleted { 124 Button("Resign", role: .destructive, action: onResign) 125 } 126 if shouldLeaveShare { 127 Button("Leave", role: .destructive, action: onLeave) 128 } else { 129 Button("Delete", role: .destructive, action: onDelete) 130 } 131 } 132 } 133 134 private var accessibilityLabel: String { 135 var parts: [String] = [game.title] 136 if game.isAccessRevoked { 137 parts.append("access revoked") 138 } else if isCompleted { 139 parts.append("completed") 140 } else { 141 parts.append("in progress") 142 } 143 if game.hasUnreadOtherMoves { 144 parts.append("unseen changes") 145 } 146 if let sharedSummary { 147 parts.append(sharedSummary) 148 } 149 if let publisher = game.publisher, !publisher.isEmpty { 150 parts.append(publisher) 151 } 152 if let puzzleDate = game.puzzleDate { 153 parts.append(puzzleDate.formatted(.dateTime.day().month(.wide).year())) 154 } 155 if let updatedAt = game.updatedAt { 156 parts.append(lastUpdatedText(from: updatedAt)) 157 } 158 return parts.joined(separator: ", ") 159 } 160 161 private var sharedSummary: String? { 162 guard game.isShared else { return nil } 163 let remoteParticipants = game.allParticipants.filter { !$0.isLocal } 164 if remoteParticipants.isEmpty { 165 return "shared puzzle" 166 } 167 if remoteParticipants.count == 1, let participant = remoteParticipants.first { 168 return "shared with \(participant.name)" 169 } 170 return "shared with \(remoteParticipants.count) players" 171 } 172 173 private func lastUpdatedText(from date: Date) -> String { 174 let elapsed = max(0, Date.now.timeIntervalSince(date)) 175 if elapsed < 60 { 176 let seconds = max(1, Int(elapsed.rounded(.down))) 177 return "last updated \(seconds) \(seconds == 1 ? "second" : "seconds") ago" 178 } 179 if elapsed < 60 * 60 { 180 let minutes = Int((elapsed / 60).rounded(.down)) 181 return "last updated \(minutes) \(minutes == 1 ? "minute" : "minutes") ago" 182 } 183 if elapsed <= 48 * 60 * 60 { 184 let hours = Int((elapsed / (60 * 60)).rounded(.down)) 185 return "last updated \(hours) \(hours == 1 ? "hour" : "hours") ago" 186 } 187 return "last updated on \(date.formatted(.dateTime.day().month(.wide).year()))" 188 } 189 } 190 191 extension View { 192 func gameSummaryAccessibility( 193 game: GameSummary, 194 onInvite: @escaping () -> Void, 195 onOpen: @escaping () -> Void, 196 onLeave: @escaping () -> Void, 197 onResign: @escaping () -> Void, 198 onDelete: @escaping () -> Void 199 ) -> some View { 200 modifier(GameSummaryAccessibility( 201 game: game, 202 onInvite: onInvite, 203 onOpen: onOpen, 204 onLeave: onLeave, 205 onResign: onResign, 206 onDelete: onDelete 207 )) 208 } 209 } 210 211 struct GameListThumbnailView: View { 212 let game: GameSummary 213 let showsUnreadBadge: Bool 214 215 private var showsParticipantStrip: Bool { 216 game.isShared && game.completedAt == nil 217 } 218 219 var body: some View { 220 VStack(spacing: 0) { 221 GridThumbnailView( 222 width: game.gridWidth, 223 height: game.gridHeight, 224 cells: game.thumbnailCells, 225 size: 60 226 ) 227 .overlay(alignment: .topTrailing) { 228 if showsUnreadBadge { 229 Circle() 230 .fill(.red) 231 .frame(width: 14, height: 14) 232 .overlay( 233 Circle() 234 .stroke(.background, lineWidth: 2) 235 ) 236 .offset(x: 5, y: -5) 237 .accessibilityLabel("Unseen changes") 238 } 239 } 240 241 if showsParticipantStrip { 242 SharedParticipantsButton( 243 stripParticipants: game.stripParticipants, 244 menuParticipants: game.allParticipants 245 ) 246 .frame(width: 60) 247 .padding(.top, 2) 248 } 249 } 250 .frame(width: 60) 251 } 252 } 253 254 struct SharedGameSymbol: View { 255 var body: some View { 256 Image(systemName: "person.2.fill") 257 .font(.caption.weight(.semibold)) 258 .foregroundStyle(.secondary) 259 .accessibilityLabel("Shared puzzle") 260 } 261 } 262 263 // MARK: - Shared overflow menu 264 265 struct GameOverflowMenu: View { 266 let game: GameSummary 267 var onShare: () -> Void 268 var onResume: () -> Void 269 var onLeave: () -> Void 270 var onResign: () -> Void 271 var onDelete: () -> Void 272 273 private var isCompleted: Bool { 274 game.completedAt != nil 275 } 276 277 private var shouldLeaveShare: Bool { 278 !isCompleted && !game.isOwned && game.isShared 279 } 280 281 var body: some View { 282 Menu { 283 if !isCompleted { 284 Button { onShare() } label: { 285 Label("Invite", systemImage: "plus.square") 286 } 287 .disabled(!game.isOwned) 288 } 289 Button { onResume() } label: { 290 if isCompleted { 291 Label("View", systemImage: "binoculars") 292 } else { 293 Label("Resume", systemImage: "puzzlepiece.extension") 294 } 295 } 296 Section { 297 if !isCompleted { 298 Button(role: .destructive) { onResign() } label: { 299 Label("Resign", systemImage: "flag") 300 } 301 } 302 if shouldLeaveShare { 303 Button(role: .destructive) { onLeave() } label: { 304 Label("Leave", systemImage: "rectangle.portrait.and.arrow.right") 305 } 306 } else { 307 Button(role: .destructive) { onDelete() } label: { 308 Label("Delete", systemImage: "trash") 309 } 310 } 311 } 312 } label: { 313 Image(systemName: "ellipsis") 314 .font(.body) 315 .frame(width: 32, height: 32) 316 .contentShape(Rectangle()) 317 } 318 .tint(.secondary) 319 .compositingGroup() 320 } 321 } 322 323 struct SharedParticipantsButton: View { 324 let stripParticipants: [GameParticipantSummary] 325 let menuParticipants: [GameParticipantSummary] 326 @State private var isShowingParticipants = false 327 328 var body: some View { 329 Button { 330 isShowingParticipants = true 331 } label: { 332 ParticipantColorStrip(participants: stripParticipants) 333 } 334 .buttonStyle(.plain) 335 .accessibilityLabel(accessibilityLabel) 336 .popover(isPresented: $isShowingParticipants) { 337 GameParticipantsPopover(participants: menuParticipants) 338 .presentationCompactAdaptation(.popover) 339 } 340 } 341 342 private var accessibilityLabel: String { 343 let remoteParticipants = menuParticipants.filter { !$0.isLocal } 344 if remoteParticipants.isEmpty { 345 return "Shared puzzle" 346 } 347 if remoteParticipants.count == 1, let participant = remoteParticipants.first { 348 return "Shared with \(participant.name)" 349 } 350 return "Shared with \(remoteParticipants.count) players" 351 } 352 } 353 354 private struct ParticipantColorStrip: View { 355 @Environment(\.displayScale) private var displayScale 356 let participants: [GameParticipantSummary] 357 358 var body: some View { 359 HStack(spacing: 0) { 360 if participants.isEmpty { 361 Rectangle() 362 .fill(Color.secondary.opacity(0.35)) 363 } else { 364 ForEach(Array(participants.enumerated()), id: \.element.id) { index, participant in 365 if index > 0 { 366 Rectangle() 367 .fill(Color(.separator)) 368 .frame(width: 1 / displayScale) 369 } 370 Rectangle() 371 .fill(participant.color.selectionFill) 372 } 373 } 374 } 375 .frame(maxWidth: .infinity) 376 .frame(height: 6) 377 .contentShape(Rectangle()) 378 } 379 } 380 381 private struct GameParticipantsPopover: View { 382 let participants: [GameParticipantSummary] 383 384 var body: some View { 385 VStack(alignment: .leading, spacing: 0) { 386 Text("Players") 387 .font(.headline) 388 .padding(.horizontal, 16) 389 .padding(.top, 14) 390 .padding(.bottom, 8) 391 392 if participants.isEmpty { 393 Text("Waiting for player…") 394 .font(.subheadline) 395 .foregroundStyle(.secondary) 396 .padding(.horizontal, 16) 397 .padding(.bottom, 16) 398 } else { 399 VStack(spacing: 0) { 400 ForEach(participants) { participant in 401 HStack(spacing: 10) { 402 Circle() 403 .fill(participant.color.selectionFill) 404 .frame(width: 14, height: 14) 405 Text(participant.isLocal ? "\(participant.name) (you)" : participant.name) 406 .font(.subheadline) 407 .lineLimit(1) 408 .truncationMode(.tail) 409 Spacer(minLength: 0) 410 } 411 .padding(.horizontal, 16) 412 .padding(.vertical, 9) 413 } 414 } 415 .padding(.bottom, 6) 416 } 417 } 418 .frame(minWidth: 220, idealWidth: 260) 419 } 420 } 421 422 struct GameMetadataView: View { 423 let puzzleDate: Date? 424 let publisher: String? 425 let usesRoomierType: Bool 426 427 private var font: Font { 428 usesRoomierType ? .subheadline : .footnote 429 } 430 431 var body: some View { 432 if let puzzleDate { 433 Text(puzzleDate, format: .dateTime.day().month(.abbreviated).year()) 434 .font(font) 435 .foregroundStyle(.secondary) 436 } 437 if let publisher { 438 Text(publisher) 439 .font(font) 440 .foregroundStyle(.secondary) 441 .lineLimit(1) 442 .truncationMode(.tail) 443 } 444 } 445 }