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 }