crossmate

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

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 }