Files
Sportstime/SportsTime/Features/Polls/Views/PollsListView.swift
Trey t c94e373e33 fix: comprehensive codebase hardening — crashes, silent failures, performance, and security
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>
2026-02-27 17:03:09 -06:00

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()
}
}