feat(polls): implement group trip polling MVP

Add complete group trip polling feature allowing users to share trips
with friends for voting using Borda count scoring.

New components:
- TripPoll and PollVote domain models with share codes and rankings
- LocalTripPoll and LocalPollVote SwiftData models for persistence
- CKTripPoll and CKPollVote CloudKit record wrappers
- PollService actor for CloudKit CRUD operations and subscriptions
- PollCreation/Detail/Voting views and view models
- Deep link handling for sportstime://poll/{code} URLs
- Debug Pro status override toggle in Settings

Integration:
- HomeView shows polls section in My Trips
- SportsTimeApp registers SwiftData models and handles deep links

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-13 21:54:42 -06:00
parent 8e78828bde
commit 13385b6562
16 changed files with 2416 additions and 19 deletions

View File

@@ -0,0 +1,77 @@
//
// PollCreationViewModel.swift
// SportsTime
//
// ViewModel for creating trip polls
//
import Foundation
import SwiftUI
@Observable
@MainActor
final class PollCreationViewModel {
var title: String = ""
var selectedTripIds: Set<UUID> = []
var isLoading = false
var error: PollError?
var createdPoll: TripPoll?
private let pollService = PollService.shared
var canCreate: Bool {
!title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& selectedTripIds.count >= 2
}
var validationMessage: String? {
if title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "Enter a title for your poll"
}
if selectedTripIds.count < 2 {
return "Select at least 2 trips to create a poll"
}
return nil
}
func createPoll(trips: [Trip]) async {
guard canCreate else { return }
isLoading = true
error = nil
do {
let userId = try await pollService.getCurrentUserRecordID()
let selectedTrips = trips.filter { selectedTripIds.contains($0.id) }
let poll = TripPoll(
title: title.trimmingCharacters(in: .whitespacesAndNewlines),
ownerId: userId,
tripSnapshots: selectedTrips
)
createdPoll = try await pollService.createPoll(poll)
} catch let pollError as PollError {
error = pollError
} catch {
self.error = .unknown(error)
}
isLoading = false
}
func toggleTrip(_ tripId: UUID) {
if selectedTripIds.contains(tripId) {
selectedTripIds.remove(tripId)
} else {
selectedTripIds.insert(tripId)
}
}
func reset() {
title = ""
selectedTripIds = []
error = nil
createdPoll = nil
}
}

View File

@@ -0,0 +1,144 @@
//
// PollDetailViewModel.swift
// SportsTime
//
// ViewModel for viewing poll details and results
//
import Foundation
import SwiftUI
@Observable
@MainActor
final class PollDetailViewModel {
var poll: TripPoll?
var votes: [PollVote] = []
var myVote: PollVote?
var isLoading = false
var isRefreshing = false
var error: PollError?
private let pollService = PollService.shared
var results: PollResults? {
guard let poll else { return nil }
return PollResults(poll: poll, votes: votes)
}
var isOwner: Bool {
get async {
guard let poll else { return false }
do {
let userId = try await pollService.getCurrentUserRecordID()
return poll.ownerId == userId
} catch {
return false
}
}
}
var hasVoted: Bool {
myVote != nil
}
var shareURL: URL? {
poll?.shareURL
}
func loadPoll(byId pollId: UUID) async {
isLoading = true
error = nil
do {
async let pollTask = pollService.fetchPoll(byId: pollId)
async let votesTask = pollService.fetchVotes(forPollId: pollId)
async let myVoteTask = pollService.fetchMyVote(forPollId: pollId)
let (fetchedPoll, fetchedVotes, fetchedMyVote) = try await (pollTask, votesTask, myVoteTask)
self.poll = fetchedPoll
self.votes = fetchedVotes
self.myVote = fetchedMyVote
// Subscribe to vote updates
try? await pollService.subscribeToVoteUpdates(forPollId: pollId)
} catch let pollError as PollError {
error = pollError
} catch {
self.error = .unknown(error)
}
isLoading = false
}
func loadPoll(byShareCode shareCode: String) async {
isLoading = true
error = nil
do {
let fetchedPoll = try await pollService.fetchPoll(byShareCode: shareCode)
self.poll = fetchedPoll
// Now fetch votes with the poll ID
async let votesTask = pollService.fetchVotes(forPollId: fetchedPoll.id)
async let myVoteTask = pollService.fetchMyVote(forPollId: fetchedPoll.id)
let (fetchedVotes, fetchedMyVote) = try await (votesTask, myVoteTask)
self.votes = fetchedVotes
self.myVote = fetchedMyVote
// Subscribe to vote updates
try? await pollService.subscribeToVoteUpdates(forPollId: fetchedPoll.id)
} catch let pollError as PollError {
error = pollError
} catch {
self.error = .unknown(error)
}
isLoading = false
}
func refresh() async {
guard let poll else { return }
isRefreshing = true
do {
async let votesTask = pollService.fetchVotes(forPollId: poll.id)
async let myVoteTask = pollService.fetchMyVote(forPollId: poll.id)
let (fetchedVotes, fetchedMyVote) = try await (votesTask, myVoteTask)
self.votes = fetchedVotes
self.myVote = fetchedMyVote
} catch {
// Silently fail refresh - user can pull to refresh again
}
isRefreshing = false
}
func deletePoll() async -> Bool {
guard let poll else { return false }
isLoading = true
error = nil
do {
try await pollService.deletePoll(poll.id)
try? await pollService.unsubscribeFromVoteUpdates()
self.poll = nil
return true
} catch let pollError as PollError {
error = pollError
} catch {
self.error = .unknown(error)
}
isLoading = false
return false
}
func cleanup() async {
try? await pollService.unsubscribeFromVoteUpdates()
}
}

View File

@@ -0,0 +1,90 @@
//
// PollVotingViewModel.swift
// SportsTime
//
// ViewModel for voting on trip polls
//
import Foundation
import SwiftUI
@Observable
@MainActor
final class PollVotingViewModel {
var rankings: [Int] = [] // Trip indices in preference order
var isLoading = false
var error: PollError?
var didSubmit = false
private let pollService = PollService.shared
var canSubmit: Bool {
!rankings.isEmpty
}
func initializeRankings(tripCount: Int, existingVote: PollVote?) {
if let vote = existingVote {
rankings = vote.rankings
} else {
// Default: trips in original order
rankings = Array(0..<tripCount)
}
}
func moveTrip(from source: IndexSet, to destination: Int) {
rankings.move(fromOffsets: source, toOffset: destination)
}
func submitVote(pollId: UUID) async {
guard canSubmit else { return }
isLoading = true
error = nil
do {
let userId = try await pollService.getCurrentUserRecordID()
let vote = PollVote(
pollId: pollId,
odg: userId,
rankings: rankings
)
_ = try await pollService.submitVote(vote)
didSubmit = true
} catch let pollError as PollError {
error = pollError
} catch {
self.error = .unknown(error)
}
isLoading = false
}
func updateVote(existingVote: PollVote) async {
guard canSubmit else { return }
isLoading = true
error = nil
do {
var updatedVote = existingVote
updatedVote.rankings = rankings
_ = try await pollService.updateVote(updatedVote)
didSubmit = true
} catch let pollError as PollError {
error = pollError
} catch {
self.error = .unknown(error)
}
isLoading = false
}
func reset() {
rankings = []
error = nil
didSubmit = false
}
}