Fixes ~95 issues from deep audit across 12 categories in 82 files: - Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files - Silent failure elimination: all 34 try? sites replaced with do/try/catch + logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService, CanonicalModels, CKModels, SportsTimeApp, and more) - Performance: cached DateFormatters (7 files), O(1) team lookups via AppDataProvider, achievement definition dictionary, AnimatedBackground consolidated from 19 Tasks to 1, task cancellation in SharePreviewView - Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard, @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix - Planning engine: game end time in travel feasibility, state-aware city normalization, exact city matching, DrivingConstraints parameter propagation - IAP: unknown subscription states → expired, unverified transaction logging, entitlements updated before paywall dismiss, restore visible to all users - Security: API key to Info.plist lookup, filename sanitization in PDF export, honest User-Agent, removed stale "Feels" analytics super properties - Navigation: consolidated competing navigationDestination, boolean → value-based - Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat - Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel MKDirections, Sendable-safe POI struct Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
174 lines
4.9 KiB
Swift
174 lines
4.9 KiB
Swift
//
|
|
// PollsListView.swift
|
|
// SportsTime
|
|
//
|
|
// View for listing user's polls
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct PollsListView: View {
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@State private var polls: [TripPoll] = []
|
|
@State private var isLoading = false
|
|
@State private var hasLoadedInitially = false
|
|
@State private var errorMessage: String?
|
|
@State private var showJoinPoll = false
|
|
@State private var joinCode = ""
|
|
@State private var pendingJoinCode: IdentifiableShareCode?
|
|
|
|
var body: some View {
|
|
Group {
|
|
if isLoading && polls.isEmpty {
|
|
ProgressView("Loading polls...")
|
|
} else if polls.isEmpty {
|
|
emptyState
|
|
} else {
|
|
pollsList
|
|
}
|
|
}
|
|
.navigationTitle("Group Polls")
|
|
.accessibilityIdentifier("polls.list")
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button {
|
|
showJoinPoll = true
|
|
} label: {
|
|
Image(systemName: "link.badge.plus")
|
|
.accessibilityLabel("Join a poll")
|
|
}
|
|
}
|
|
}
|
|
.refreshable {
|
|
await loadPolls()
|
|
}
|
|
.task {
|
|
guard !hasLoadedInitially else { return }
|
|
hasLoadedInitially = true
|
|
await loadPolls()
|
|
}
|
|
.alert("Join Poll", isPresented: $showJoinPoll) {
|
|
TextField("Enter code", text: $joinCode)
|
|
.textInputAutocapitalization(.characters)
|
|
Button("Join") {
|
|
let normalizedCode = joinCode
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.uppercased()
|
|
|
|
guard normalizedCode.count == 6 else {
|
|
errorMessage = "Share code must be exactly 6 characters."
|
|
return
|
|
}
|
|
|
|
pendingJoinCode = IdentifiableShareCode(id: normalizedCode)
|
|
joinCode = ""
|
|
}
|
|
Button("Cancel", role: .cancel) {
|
|
joinCode = ""
|
|
}
|
|
} message: {
|
|
Text("Enter the 6-character poll code")
|
|
}
|
|
.navigationDestination(item: $pendingJoinCode) { code in
|
|
PollDetailView(shareCode: code.value)
|
|
}
|
|
.navigationDestination(for: TripPoll.self) { poll in
|
|
PollDetailView(poll: poll)
|
|
}
|
|
.alert(
|
|
"Error",
|
|
isPresented: Binding(
|
|
get: { errorMessage != nil },
|
|
set: { if !$0 { errorMessage = nil } }
|
|
)
|
|
) {
|
|
Button("OK", role: .cancel) {
|
|
errorMessage = nil
|
|
}
|
|
} message: {
|
|
Text(errorMessage ?? "Please try again.")
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var emptyState: some View {
|
|
ContentUnavailableView {
|
|
Label("No Polls", systemImage: "chart.bar.doc.horizontal")
|
|
} description: {
|
|
Text("Create a poll from your saved trips to let friends vote on which trip to take.")
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var pollsList: some View {
|
|
List {
|
|
ForEach(polls) { poll in
|
|
NavigationLink(value: poll) {
|
|
PollRowView(poll: poll)
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
}
|
|
|
|
private func loadPolls() async {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
|
|
do {
|
|
polls = try await PollService.shared.fetchMyPolls()
|
|
} catch let pollError as PollError {
|
|
errorMessage = pollError.localizedDescription
|
|
} catch {
|
|
self.errorMessage = PollError.unknown(error).localizedDescription
|
|
}
|
|
|
|
isLoading = false
|
|
}
|
|
}
|
|
|
|
// MARK: - Poll Row View
|
|
|
|
private struct PollRowView: View {
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
let poll: TripPoll
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text(poll.title)
|
|
.font(.headline)
|
|
|
|
Spacer()
|
|
|
|
Text(poll.shareCode)
|
|
.font(.caption)
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(Theme.warmOrange)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(Theme.warmOrange.opacity(0.15))
|
|
.clipShape(Capsule())
|
|
}
|
|
|
|
HStack {
|
|
Label("\(poll.tripSnapshots.count) trips", systemImage: "map")
|
|
|
|
Spacer()
|
|
|
|
Text(poll.createdAt, style: .date)
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
PollsListView()
|
|
}
|
|
}
|