// // PollDetailView.swift // SportsTime // // View for displaying poll details and results // import SwiftUI struct PollDetailView: View { @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme @State private var viewModel = PollDetailViewModel() @State private var showShareSheet = false @State private var showDeleteConfirmation = false @State private var showVotingSheet = false @State private var selectedTrip: Trip? @State private var isOwner = false let pollId: UUID? let shareCode: String? let initialPoll: TripPoll? init(pollId: UUID) { self.pollId = pollId self.shareCode = nil self.initialPoll = nil } init(shareCode: String) { self.pollId = nil self.shareCode = shareCode self.initialPoll = nil } /// Initialize with a poll object directly (avoids fetch delay) init(poll: TripPoll) { self.pollId = poll.id self.shareCode = nil self.initialPoll = poll } var body: some View { Group { if viewModel.isLoading && viewModel.poll == nil { ProgressView("Loading poll...") } else if let poll = viewModel.poll { pollContent(poll) } else if let error = viewModel.error { ContentUnavailableView( "Poll Not Found", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription) ) } } .navigationTitle(viewModel.poll?.title ?? "Poll") .navigationBarTitleDisplayMode(.inline) .toolbar { if viewModel.poll != nil { ToolbarItem(placement: .primaryAction) { Menu { Button { showShareSheet = true } label: { Label("Share Poll", systemImage: "square.and.arrow.up") } if isOwner { Divider() Button(role: .destructive) { showDeleteConfirmation = true } label: { Label("Delete Poll", systemImage: "trash") } } } label: { Image(systemName: "ellipsis.circle") } } } } .refreshable { await viewModel.refresh() } .task { await loadPoll() isOwner = await viewModel.isOwner } .task(id: viewModel.poll?.id) { if viewModel.poll != nil { isOwner = await viewModel.isOwner } } .onDisappear { Task { await viewModel.cleanup() } } .sheet(isPresented: $showShareSheet) { if let url = viewModel.shareURL { ShareSheet(items: [url]) } } .sheet(isPresented: $showVotingSheet) { if let poll = viewModel.poll { PollVotingView(poll: poll, existingVote: viewModel.myVote) { Task { await viewModel.refresh() } } } } .confirmationDialog("Delete Poll", isPresented: $showDeleteConfirmation, titleVisibility: .visible) { Button("Delete", role: .destructive) { Task { if await viewModel.deletePoll() { dismiss() } } } Button("Cancel", role: .cancel) {} } message: { Text("This will permanently delete the poll and all votes. This action cannot be undone.") } .sheet(item: $selectedTrip) { trip in NavigationStack { TripDetailView(trip: trip, allowCustomItems: true) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { selectedTrip = nil } } } } } } @ViewBuilder private func pollContent(_ poll: TripPoll) -> some View { ScrollView { VStack(spacing: Theme.Spacing.lg) { // Share Code Card shareCodeCard(poll) // Voting Status votingStatusCard // Results if let results = viewModel.results { resultsSection(results) } // Trip Previews tripPreviewsSection(poll) } .padding(.horizontal, Theme.Spacing.md) .padding(.vertical, Theme.Spacing.lg) } .background(Theme.backgroundGradient(colorScheme)) } @ViewBuilder private func shareCodeCard(_ poll: TripPoll) -> some View { VStack(spacing: Theme.Spacing.md) { // Icon ZStack { Circle() .fill(Theme.warmOrange.opacity(0.15)) .frame(width: 56, height: 56) Image(systemName: "link.circle.fill") .font(.system(size: 28)) .foregroundStyle(Theme.warmOrange) } VStack(spacing: Theme.Spacing.xs) { Text("Share Code") .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) Text(poll.shareCode) .font(.system(size: 36, weight: .bold, design: .monospaced)) .foregroundStyle(Theme.warmOrange) .tracking(4) } // Copy button Button { UIPasteboard.general.string = poll.shareCode } label: { Label("Copy Code", systemImage: "doc.on.doc") .font(.subheadline.weight(.medium)) .foregroundStyle(Theme.warmOrange) .padding(.horizontal, Theme.Spacing.md) .padding(.vertical, Theme.Spacing.sm) .background(Theme.warmOrange.opacity(0.1)) .clipShape(Capsule()) } } .frame(maxWidth: .infinity) .padding(Theme.Spacing.lg) .background(Theme.cardBackground(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) .overlay( RoundedRectangle(cornerRadius: Theme.CornerRadius.large) .strokeBorder(Theme.warmOrange.opacity(0.2), lineWidth: 1) ) } @ViewBuilder private var votingStatusCard: some View { HStack(spacing: Theme.Spacing.md) { // Status icon ZStack { Circle() .fill(viewModel.hasVoted ? Theme.mlsGreen.opacity(0.15) : Theme.warmOrange.opacity(0.15)) .frame(width: 44, height: 44) Image(systemName: viewModel.hasVoted ? "checkmark.circle.fill" : "hand.raised.fill") .font(.title3) .foregroundStyle(viewModel.hasVoted ? Theme.mlsGreen : Theme.warmOrange) } VStack(alignment: .leading, spacing: 2) { Text(viewModel.hasVoted ? "You voted" : "Cast your vote") .font(.headline) .foregroundStyle(Theme.textPrimary(colorScheme)) HStack(spacing: Theme.Spacing.xs) { Image(systemName: "person.2.fill") .font(.caption2) Text("\(viewModel.votes.count) vote\(viewModel.votes.count == 1 ? "" : "s")") .font(.subheadline) } .foregroundStyle(Theme.textSecondary(colorScheme)) } Spacer() Button { showVotingSheet = true } label: { Text(viewModel.hasVoted ? "Change" : "Vote") .font(.subheadline.weight(.semibold)) .foregroundStyle(.white) .padding(.horizontal, Theme.Spacing.md) .padding(.vertical, Theme.Spacing.sm) .background(Theme.warmOrange) .clipShape(Capsule()) } } .padding(Theme.Spacing.md) .background(Theme.cardBackground(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } @ViewBuilder private func resultsSection(_ results: PollResults) -> some View { VStack(alignment: .leading, spacing: Theme.Spacing.md) { // Section header HStack(spacing: Theme.Spacing.sm) { Image(systemName: "chart.bar.fill") .foregroundStyle(Theme.warmOrange) Text("Results") .font(.headline) .foregroundStyle(Theme.textPrimary(colorScheme)) } VStack(spacing: Theme.Spacing.sm) { ForEach(results.tripScores, id: \.tripIndex) { item in let trip = results.poll.tripSnapshots[item.tripIndex] let rank = results.tripScores.firstIndex { $0.tripIndex == item.tripIndex }! + 1 ResultRow( rank: rank, tripName: trip.displayName, score: item.score, percentage: results.scorePercentage(for: item.tripIndex), isLeader: rank == 1 && item.score > 0 ) } } } .padding(Theme.Spacing.md) .background(Theme.cardBackground(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } @ViewBuilder private func tripPreviewsSection(_ poll: TripPoll) -> some View { VStack(alignment: .leading, spacing: Theme.Spacing.md) { // Section header HStack(spacing: Theme.Spacing.sm) { Image(systemName: "map.fill") .foregroundStyle(Theme.warmOrange) Text("Trip Options") .font(.headline) .foregroundStyle(Theme.textPrimary(colorScheme)) Spacer() Text("\(poll.tripSnapshots.count) trips") .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) } ForEach(Array(poll.tripSnapshots.enumerated()), id: \.element.id) { index, trip in Button { selectedTrip = trip } label: { TripPreviewCard(trip: trip, index: index + 1) } .buttonStyle(.plain) } } } private func loadPoll() async { // If we have an initial poll object, use it directly to avoid CloudKit fetch delay if let initialPoll { await viewModel.loadPoll(from: initialPoll) } else if let pollId { await viewModel.loadPoll(byId: pollId) } else if let shareCode { await viewModel.loadPoll(byShareCode: shareCode) } } } // MARK: - Result Row private struct ResultRow: View { @Environment(\.colorScheme) private var colorScheme let rank: Int let tripName: String let score: Int let percentage: Double var isLeader: Bool = false private var rankIcon: String { switch rank { case 1: return "trophy.fill" case 2: return "medal.fill" case 3: return "medal.fill" default: return "\(rank).circle.fill" } } private var rankColor: Color { switch rank { case 1: return Theme.warmOrange case 2: return .gray case 3: return .brown default: return .secondary } } var body: some View { HStack(spacing: Theme.Spacing.sm) { // Rank badge ZStack { Circle() .fill(rankColor.opacity(0.15)) .frame(width: 36, height: 36) Image(systemName: rankIcon) .font(.subheadline) .foregroundStyle(rankColor) } VStack(alignment: .leading, spacing: 4) { Text(tripName) .font(.subheadline.weight(isLeader ? .semibold : .regular)) .foregroundStyle(Theme.textPrimary(colorScheme)) GeometryReader { geometry in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 4) .fill(Theme.cardBackgroundElevated(colorScheme)) .frame(height: 6) RoundedRectangle(cornerRadius: 4) .fill(rankColor) .frame(width: max(geometry.size.width * percentage, percentage > 0 ? 6 : 0), height: 6) } } .frame(height: 6) } // Score badge Text("\(score)") .font(.subheadline.weight(.medium).monospacedDigit()) .foregroundStyle(rankColor) .padding(.horizontal, 10) .padding(.vertical, 4) .background(rankColor.opacity(0.1)) .clipShape(Capsule()) } .padding(.vertical, Theme.Spacing.xs) } } // MARK: - Trip Preview Card private struct TripPreviewCard: View { @Environment(\.colorScheme) private var colorScheme let trip: Trip let index: Int var body: some View { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 8) { HStack { Text("Option \(index)") .font(.caption) .fontWeight(.semibold) .foregroundStyle(.white) .padding(.horizontal, 8) .padding(.vertical, 4) .background(Theme.warmOrange) .clipShape(Capsule()) Text(trip.displayName) .font(.headline) .foregroundStyle(.primary) } // Date range and duration HStack { Label(trip.formattedDateRange, systemImage: "calendar") Spacer() Label("\(trip.tripDuration) days", systemImage: "clock") } .font(.caption) .foregroundStyle(.secondary) HStack { Label("\(trip.stops.count) stops", systemImage: "mappin.and.ellipse") Spacer() Label("\(trip.stops.flatMap { $0.games }.count) games", systemImage: "sportscourt") } .font(.caption) .foregroundStyle(.secondary) } Image(systemName: "chevron.right") .font(.caption) .fontWeight(.semibold) .foregroundStyle(.tertiary) } .padding() .background(Theme.cardBackground(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: 12)) .overlay( RoundedRectangle(cornerRadius: 12) .strokeBorder(Color.secondary.opacity(0.1), lineWidth: 1) ) } } #Preview { NavigationStack { PollDetailView(shareCode: "ABC123") } }