crossmate

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

GameListView.swift (13624B)


      1 import CoreData
      2 import SwiftUI
      3 
      4 struct GameListView: View {
      5     let store: GameStore
      6     let shareController: ShareController
      7     let onRefresh: () async -> Void
      8     @Binding var navigationPath: NavigationPath
      9 
     10     @Environment(\.managedObjectContext) private var viewContext
     11     @Environment(\.dynamicTypeSize) private var dynamicTypeSize
     12     @FetchRequest(
     13         sortDescriptors: [],
     14         animation: .default
     15     )
     16     private var games: FetchedResults<GameEntity>
     17 
     18     @State private var showingNewGame = false
     19     @State private var showingSettings = false
     20     @State private var deleteTarget: GameSummary?
     21     @State private var resignTarget: GameSummary?
     22     @State private var leaveTarget: GameSummary?
     23     @State private var leaveError: Error?
     24     @State private var summaryCache = GameSummaryCache()
     25 
     26     var body: some View {
     27         GeometryReader { geometry in
     28             content(usesRoomierType: usesRoomierType(for: geometry.size))
     29         }
     30         .navigationTitle("")
     31         .navigationBarTitleDisplayMode(.inline)
     32         .toolbar {
     33             ToolbarItem(placement: .topBarLeading) {
     34                 Button {
     35                     showingSettings = true
     36                 } label: {
     37                     Image(systemName: "gearshape")
     38                 }
     39             }
     40             ToolbarItem(placement: .topBarTrailing) {
     41                 Button {
     42                     showingNewGame = true
     43                 } label: {
     44                     Image(systemName: "plus")
     45                 }
     46             }
     47         }
     48         .sheet(isPresented: $showingSettings) {
     49             SettingsView()
     50         }
     51         .sheet(isPresented: $showingNewGame) {
     52             NewGameSheet(store: store)
     53         }
     54         .alert("Resign Puzzle?", isPresented: .init(
     55             get: { resignTarget != nil },
     56             set: { if !$0 { resignTarget = nil } }
     57         )) {
     58             Button("Resign", role: .destructive) {
     59                 if let target = resignTarget {
     60                     try? store.resignGame(id: target.id)
     61                 }
     62             }
     63             Button("Cancel", role: .cancel) {}
     64         } message: {
     65             if let target = resignTarget {
     66                 Text("This will reveal all answers for \"\(target.title)\".")
     67             }
     68         }
     69         .alert("Leave Puzzle?", isPresented: .init(
     70             get: { leaveTarget != nil },
     71             set: { if !$0 { leaveTarget = nil } }
     72         )) {
     73             Button("Leave", role: .destructive) {
     74                 if let target = leaveTarget {
     75                     Task { await leaveShare(game: target) }
     76                 }
     77             }
     78             Button("Cancel", role: .cancel) {}
     79         } message: {
     80             if let target = leaveTarget {
     81                 Text("You will lose access to \"\(target.title)\".")
     82             }
     83         }
     84         .alert("Delete Puzzle?", isPresented: .init(
     85             get: { deleteTarget != nil },
     86             set: { if !$0 { deleteTarget = nil } }
     87         )) {
     88             Button("Delete", role: .destructive) {
     89                 if let target = deleteTarget {
     90                     try? store.deleteGame(id: target.id)
     91                 }
     92             }
     93             Button("Cancel", role: .cancel) {}
     94         } message: {
     95             if let target = deleteTarget {
     96                 if target.isOwned && target.isShared {
     97                     Text("This will permanently delete \"\(target.title)\" from iCloud for everyone.")
     98                 } else {
     99                     Text("This will permanently delete \"\(target.title)\" and all progress.")
    100                 }
    101             }
    102         }
    103     }
    104 
    105     @ViewBuilder
    106     private func content(usesRoomierType: Bool) -> some View {
    107         let summaries = games.compactMap { summaryCache.summary(for: $0) }
    108         let inProgress = summaries
    109             .filter { $0.completedAt == nil }
    110             .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
    111         let completed = summaries
    112             .filter { $0.completedAt != nil }
    113             .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
    114 
    115         Group {
    116             List {
    117                 if !inProgress.isEmpty {
    118                     Section {
    119                         ForEach(inProgress) { game in
    120                             rowView(for: game, usesRoomierType: usesRoomierType)
    121                         }
    122                     } header: {
    123                         Text("In Progress")
    124                     }
    125                 }
    126 
    127                 if !completed.isEmpty {
    128                     Section {
    129                         ForEach(completed) { game in
    130                             rowView(for: game, usesRoomierType: usesRoomierType)
    131                         }
    132                     } header: {
    133                         Text("Completed")
    134                     }
    135                 }
    136             }
    137             .overlay {
    138                 if games.isEmpty {
    139                     ContentUnavailableView {
    140                         Label("No Puzzles", systemImage: "square.grid.3x3")
    141                     } description: {
    142                         Text("Tap the + button to start a new puzzle, or pull down to refresh.")
    143                     }
    144                 }
    145             }
    146             .refreshable {
    147                 await onRefresh()
    148             }
    149         }
    150     }
    151 
    152     @ViewBuilder
    153     private func rowView(for game: GameSummary, usesRoomierType: Bool) -> some View {
    154         GameRowView(
    155             game: game,
    156             shareController: shareController,
    157             usesRoomierType: usesRoomierType,
    158             onResume: { navigationPath.append(game.id) },
    159             onLeave: { leaveTarget = game },
    160             onResign: { resignTarget = game },
    161             onDelete: { deleteTarget = game }
    162         )
    163         .background(
    164             NavigationLink(value: game.id) { EmptyView() }
    165                 .opacity(0)
    166         )
    167         .swipeActions(edge: .trailing, allowsFullSwipe: false) {
    168             if !game.isOwned && game.isShared {
    169                 Button("Leave", role: .destructive) {
    170                     leaveTarget = game
    171                 }
    172             } else {
    173                 Button("Delete", role: .destructive) {
    174                     deleteTarget = game
    175                 }
    176             }
    177         }
    178     }
    179 
    180     private func leaveShare(game: GameSummary) async {
    181         do {
    182             try await shareController.leaveShare(gameID: game.id)
    183             leaveTarget = nil
    184         } catch {
    185             leaveError = error
    186             leaveTarget = nil
    187         }
    188     }
    189 
    190     private func usesRoomierType(for size: CGSize) -> Bool {
    191         size.height >= 760 && dynamicTypeSize <= .medium
    192     }
    193 }
    194 
    195 // MARK: - Row
    196 
    197 private struct GameRowView: View {
    198     let game: GameSummary
    199     let shareController: ShareController
    200     let usesRoomierType: Bool
    201     var onResume: () -> Void = {}
    202     var onLeave: () -> Void = {}
    203     var onResign: () -> Void = {}
    204     var onDelete: () -> Void = {}
    205     @State private var isShowingShareSheet = false
    206 
    207     var body: some View {
    208         let showsUnseenBadge = game.hasUnseenOtherMoves
    209 
    210         HStack(spacing: 12) {
    211             GridThumbnailView(
    212                 width: game.gridWidth,
    213                 height: game.gridHeight,
    214                 cells: game.thumbnailCells
    215             )
    216             .overlay(alignment: .topTrailing) {
    217                 if showsUnseenBadge {
    218                     Circle()
    219                         .fill(.red)
    220                         .frame(width: 14, height: 14)
    221                         .overlay(
    222                             Circle()
    223                                 .stroke(.background, lineWidth: 2)
    224                         )
    225                         .offset(x: 5, y: -5)
    226                         .accessibilityLabel("Unseen changes")
    227                 }
    228             }
    229             VStack(alignment: .leading, spacing: 2) {
    230                 HStack(spacing: 4) {
    231                     Text(game.title)
    232                         .font((usesRoomierType ? Font.headline : .subheadline).weight(.semibold))
    233                         .lineLimit(1)
    234                         .minimumScaleFactor(0.8)
    235                         .truncationMode(.tail)
    236                     if game.isShared {
    237                         Image(systemName: "person.2.fill")
    238                             .font(.caption)
    239                             .foregroundStyle(.secondary)
    240                     }
    241                 }
    242                 GameMetadataView(
    243                     puzzleDate: game.puzzleDate,
    244                     publisher: game.publisher,
    245                     usesRoomierType: usesRoomierType
    246                 )
    247                 if let date = game.updatedAt {
    248                     LastUpdatedView(date: date, usesRoomierType: usesRoomierType)
    249                 }
    250             }
    251             Spacer()
    252             Menu {
    253                 Button {
    254                     isShowingShareSheet = true
    255                 } label: {
    256                     Label("Share", systemImage: "square.and.arrow.up")
    257                 }
    258                 .disabled(!game.isOwned)
    259                 Button { onResume() } label: { Label("Resume", systemImage: "square.and.pencil") }
    260                 Section {
    261                     Button(role: .destructive) { onResign() } label: { Label("Resign", systemImage: "flag") }
    262                     if !game.isOwned && game.isShared {
    263                         Button(role: .destructive) { onLeave() } label: {
    264                             Label("Leave", systemImage: "rectangle.portrait.and.arrow.right")
    265                         }
    266                     } else {
    267                         Button(role: .destructive) { onDelete() } label: {
    268                             Label("Delete", systemImage: "trash")
    269                         }
    270                     }
    271                 }
    272             } label: {
    273                 Image(systemName: "ellipsis")
    274                     .font(.body)
    275                     .frame(width: 32, height: 32)
    276                     .contentShape(Rectangle())
    277             }
    278             .tint(.secondary)
    279             .compositingGroup()
    280         }
    281         .padding(.vertical, 4)
    282         .sheet(isPresented: $isShowingShareSheet) {
    283             GameShareSheet(
    284                 gameID: game.id,
    285                 title: game.title,
    286                 shareController: shareController
    287             )
    288         }
    289     }
    290 }
    291 
    292 private struct LastUpdatedView: View {
    293     let date: Date
    294     let usesRoomierType: Bool
    295 
    296     var body: some View {
    297         TimelineView(.lastUpdated(from: date)) { context in
    298             Text(text(now: context.date))
    299                 .font(usesRoomierType ? .footnote : .caption)
    300                 .foregroundStyle(.secondary)
    301         }
    302     }
    303 
    304     private func text(now: Date) -> String {
    305         let elapsed = max(0, now.timeIntervalSince(date))
    306         if elapsed < 60 {
    307             let seconds = max(1, Int(elapsed.rounded(.down)))
    308             return "Last updated \(seconds) \(seconds == 1 ? "second" : "seconds") ago"
    309         }
    310         if elapsed < 60 * 60 {
    311             let minutes = Int((elapsed / 60).rounded(.down))
    312             return "Last updated \(minutes) \(minutes == 1 ? "minute" : "minutes") ago"
    313         }
    314         if elapsed <= 48 * 60 * 60 {
    315             let hours = Int((elapsed / (60 * 60)).rounded(.down))
    316             return "Last updated \(hours) \(hours == 1 ? "hour" : "hours") ago"
    317         }
    318         return "Last updated on \(date.formatted(.dateTime.day().month(.abbreviated).year()))"
    319     }
    320 }
    321 
    322 private struct LastUpdatedSchedule: TimelineSchedule {
    323     let anchor: Date
    324 
    325     func entries(from startDate: Date, mode: TimelineScheduleMode) -> AnyIterator<Date> {
    326         var next = startDate
    327         return AnyIterator {
    328             let current = next
    329             let elapsed = max(0, current.timeIntervalSince(anchor))
    330             let step: TimeInterval
    331             if elapsed < 60 {
    332                 step = 1
    333             } else if elapsed < 60 * 60 {
    334                 step = 60
    335             } else if elapsed <= 48 * 60 * 60 {
    336                 step = 60 * 60
    337             } else {
    338                 return nil
    339             }
    340             next = current.addingTimeInterval(step)
    341             return current
    342         }
    343     }
    344 }
    345 
    346 extension TimelineSchedule where Self == LastUpdatedSchedule {
    347     static func lastUpdated(from date: Date) -> LastUpdatedSchedule {
    348         LastUpdatedSchedule(anchor: date)
    349     }
    350 }
    351 
    352 private struct GameMetadataView: View {
    353     let puzzleDate: Date?
    354     let publisher: String?
    355     let usesRoomierType: Bool
    356 
    357     private var font: Font {
    358         usesRoomierType ? .subheadline : .footnote
    359     }
    360 
    361     var body: some View {
    362         if let puzzleDate, let publisher {
    363             ViewThatFits(in: .horizontal) {
    364                 HStack(spacing: 0) {
    365                     Text(puzzleDate, format: .dateTime.day().month(.abbreviated).year())
    366                     Text(" • ")
    367                     Text(publisher)
    368                 }
    369                 .font(font)
    370                 .lineLimit(1)
    371 
    372                 VStack(alignment: .leading, spacing: 2) {
    373                     puzzleDateView(puzzleDate)
    374                     publisherView(publisher)
    375                 }
    376             }
    377         } else {
    378             if let puzzleDate {
    379                 puzzleDateView(puzzleDate)
    380             }
    381             if let publisher {
    382                 publisherView(publisher)
    383             }
    384         }
    385     }
    386 
    387     private func puzzleDateView(_ puzzleDate: Date) -> some View {
    388         Text(puzzleDate, format: .dateTime.day().month(.abbreviated).year())
    389             .font(font)
    390     }
    391 
    392     private func publisherView(_ publisher: String) -> some View {
    393         Text(publisher)
    394             .font(font)
    395             .lineLimit(1)
    396             .truncationMode(.tail)
    397     }
    398 }