GameCardView.swift (10735B)
1 import SwiftUI 2 3 // MARK: - Card (regular width) 4 5 enum CardMetrics { 6 static let height: CGFloat = 88 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((usesRoomierType ? Font.headline : .subheadline).weight(.semibold)) 34 .lineLimit(1) 35 .minimumScaleFactor(0.8) 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 .sheet(isPresented: $isShowingShareSheet) { 76 GameShareSheet( 77 gameID: game.id, 78 title: game.title, 79 shareController: shareController 80 ) 81 } 82 } 83 } 84 85 struct GameListThumbnailView: View { 86 let game: GameSummary 87 let showsUnreadBadge: Bool 88 89 private var showsParticipantStrip: Bool { 90 game.isShared && game.completedAt == nil 91 } 92 93 var body: some View { 94 VStack(spacing: 0) { 95 GridThumbnailView( 96 width: game.gridWidth, 97 height: game.gridHeight, 98 cells: game.thumbnailCells, 99 size: 60 100 ) 101 .overlay(alignment: .topTrailing) { 102 if showsUnreadBadge { 103 Circle() 104 .fill(.red) 105 .frame(width: 14, height: 14) 106 .overlay( 107 Circle() 108 .stroke(.background, lineWidth: 2) 109 ) 110 .offset(x: 5, y: -5) 111 .accessibilityLabel("Unseen changes") 112 } 113 } 114 115 if showsParticipantStrip { 116 SharedParticipantsButton( 117 stripParticipants: game.stripParticipants, 118 menuParticipants: game.allParticipants 119 ) 120 .frame(width: 60) 121 .padding(.top, 2) 122 } 123 } 124 .frame(width: 60) 125 } 126 } 127 128 struct SharedGameSymbol: View { 129 var body: some View { 130 Image(systemName: "person.2.fill") 131 .font(.caption.weight(.semibold)) 132 .foregroundStyle(.secondary) 133 .accessibilityLabel("Shared puzzle") 134 } 135 } 136 137 // MARK: - Shared overflow menu 138 139 struct GameOverflowMenu: View { 140 let game: GameSummary 141 var onShare: () -> Void 142 var onResume: () -> Void 143 var onLeave: () -> Void 144 var onResign: () -> Void 145 var onDelete: () -> Void 146 147 var body: some View { 148 Menu { 149 Button { onShare() } label: { 150 Label("Share", systemImage: "square.and.arrow.up") 151 } 152 .disabled(!game.isOwned) 153 Button { onResume() } label: { 154 Label("Resume", systemImage: "puzzlepiece.extension") 155 } 156 Section { 157 Button(role: .destructive) { onResign() } label: { 158 Label("Resign", systemImage: "flag") 159 } 160 if !game.isOwned && game.isShared { 161 Button(role: .destructive) { onLeave() } label: { 162 Label("Leave", systemImage: "rectangle.portrait.and.arrow.right") 163 } 164 } else { 165 Button(role: .destructive) { onDelete() } label: { 166 Label("Delete", systemImage: "trash") 167 } 168 } 169 } 170 } label: { 171 Image(systemName: "ellipsis") 172 .font(.body) 173 .frame(width: 32, height: 32) 174 .contentShape(Rectangle()) 175 } 176 .tint(.secondary) 177 .compositingGroup() 178 } 179 } 180 181 struct SharedParticipantsButton: View { 182 let stripParticipants: [GameParticipantSummary] 183 let menuParticipants: [GameParticipantSummary] 184 @State private var isShowingParticipants = false 185 186 var body: some View { 187 Button { 188 isShowingParticipants = true 189 } label: { 190 ParticipantColorStrip(participants: stripParticipants) 191 } 192 .buttonStyle(.plain) 193 .accessibilityLabel(accessibilityLabel) 194 .popover(isPresented: $isShowingParticipants) { 195 GameParticipantsPopover(participants: menuParticipants) 196 .presentationCompactAdaptation(.popover) 197 } 198 } 199 200 private var accessibilityLabel: String { 201 let remoteParticipants = menuParticipants.filter { !$0.isLocal } 202 if remoteParticipants.isEmpty { 203 return "Shared puzzle" 204 } 205 if remoteParticipants.count == 1, let participant = remoteParticipants.first { 206 return "Shared with \(participant.name)" 207 } 208 return "Shared with \(remoteParticipants.count) players" 209 } 210 } 211 212 private struct ParticipantColorStrip: View { 213 @Environment(\.displayScale) private var displayScale 214 let participants: [GameParticipantSummary] 215 216 var body: some View { 217 HStack(spacing: 0) { 218 if participants.isEmpty { 219 Rectangle() 220 .fill(Color.secondary.opacity(0.35)) 221 } else { 222 ForEach(Array(participants.enumerated()), id: \.element.id) { index, participant in 223 if index > 0 { 224 Rectangle() 225 .fill(Color(.separator)) 226 .frame(width: 1 / displayScale) 227 } 228 Rectangle() 229 .fill(participant.color.selectionFill) 230 } 231 } 232 } 233 .frame(maxWidth: .infinity) 234 .frame(height: 6) 235 .contentShape(Rectangle()) 236 } 237 } 238 239 private struct GameParticipantsPopover: View { 240 let participants: [GameParticipantSummary] 241 242 var body: some View { 243 VStack(alignment: .leading, spacing: 0) { 244 Text("Players") 245 .font(.headline) 246 .padding(.horizontal, 16) 247 .padding(.top, 14) 248 .padding(.bottom, 8) 249 250 if participants.isEmpty { 251 Text("Waiting for player...") 252 .font(.subheadline) 253 .foregroundStyle(.secondary) 254 .padding(.horizontal, 16) 255 .padding(.bottom, 16) 256 } else { 257 VStack(spacing: 0) { 258 ForEach(participants) { participant in 259 HStack(spacing: 10) { 260 Circle() 261 .fill(participant.color.selectionFill) 262 .frame(width: 14, height: 14) 263 Text(participant.isLocal ? "\(participant.name) (you)" : participant.name) 264 .font(.subheadline) 265 .lineLimit(1) 266 .truncationMode(.tail) 267 Spacer(minLength: 0) 268 } 269 .padding(.horizontal, 16) 270 .padding(.vertical, 9) 271 } 272 } 273 .padding(.bottom, 6) 274 } 275 } 276 .frame(minWidth: 220, idealWidth: 260) 277 } 278 } 279 280 struct GameMetadataView: View { 281 let puzzleDate: Date? 282 let publisher: String? 283 let usesRoomierType: Bool 284 285 private var font: Font { 286 usesRoomierType ? .subheadline : .footnote 287 } 288 289 var body: some View { 290 if let puzzleDate, let publisher { 291 ViewThatFits(in: .horizontal) { 292 HStack(spacing: 0) { 293 Text(puzzleDate, format: .dateTime.day().month(.abbreviated).year()) 294 Text(" • ") 295 Text(publisher) 296 } 297 .font(font) 298 .lineLimit(1) 299 300 VStack(alignment: .leading, spacing: 2) { 301 puzzleDateView(puzzleDate) 302 publisherView(publisher) 303 } 304 } 305 } else { 306 if let puzzleDate { 307 puzzleDateView(puzzleDate) 308 } 309 if let publisher { 310 publisherView(publisher) 311 } 312 } 313 } 314 315 private func puzzleDateView(_ puzzleDate: Date) -> some View { 316 Text(puzzleDate, format: .dateTime.day().month(.abbreviated).year()) 317 .font(font) 318 } 319 320 private func publisherView(_ publisher: String) -> some View { 321 Text(publisher) 322 .font(font) 323 .lineLimit(1) 324 .truncationMode(.tail) 325 } 326 }