crossmate

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

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 }