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

@@ -18,6 +18,8 @@ enum CKRecordType {
static let leagueStructure = "LeagueStructure" static let leagueStructure = "LeagueStructure"
static let teamAlias = "TeamAlias" static let teamAlias = "TeamAlias"
static let stadiumAlias = "StadiumAlias" static let stadiumAlias = "StadiumAlias"
static let tripPoll = "TripPoll"
static let pollVote = "PollVote"
} }
// MARK: - CKTeam // MARK: - CKTeam
@@ -472,3 +474,114 @@ struct CKTeamAlias {
) )
} }
} }
// MARK: - CKTripPoll
struct CKTripPoll {
static let pollIdKey = "pollId"
static let titleKey = "title"
static let ownerIdKey = "ownerId"
static let shareCodeKey = "shareCode"
static let tripSnapshotsKey = "tripSnapshots"
static let tripVersionsKey = "tripVersions"
static let createdAtKey = "createdAt"
static let modifiedAtKey = "modifiedAt"
let record: CKRecord
init(record: CKRecord) {
self.record = record
}
init(poll: TripPoll) {
let record = CKRecord(recordType: CKRecordType.tripPoll, recordID: CKRecord.ID(recordName: poll.id.uuidString))
record[CKTripPoll.pollIdKey] = poll.id.uuidString
record[CKTripPoll.titleKey] = poll.title
record[CKTripPoll.ownerIdKey] = poll.ownerId
record[CKTripPoll.shareCodeKey] = poll.shareCode
// Encode trips as JSON data
if let tripsData = try? JSONEncoder().encode(poll.tripSnapshots) {
record[CKTripPoll.tripSnapshotsKey] = tripsData
}
record[CKTripPoll.tripVersionsKey] = poll.tripVersions
record[CKTripPoll.createdAtKey] = poll.createdAt
record[CKTripPoll.modifiedAtKey] = poll.modifiedAt
self.record = record
}
func toPoll() -> TripPoll? {
guard let pollIdString = record[CKTripPoll.pollIdKey] as? String,
let pollId = UUID(uuidString: pollIdString),
let title = record[CKTripPoll.titleKey] as? String,
let ownerId = record[CKTripPoll.ownerIdKey] as? String,
let shareCode = record[CKTripPoll.shareCodeKey] as? String,
let tripsData = record[CKTripPoll.tripSnapshotsKey] as? Data,
let tripSnapshots = try? JSONDecoder().decode([Trip].self, from: tripsData),
let tripVersions = record[CKTripPoll.tripVersionsKey] as? [String],
let createdAt = record[CKTripPoll.createdAtKey] as? Date,
let modifiedAt = record[CKTripPoll.modifiedAtKey] as? Date
else { return nil }
var poll = TripPoll(
id: pollId,
title: title,
ownerId: ownerId,
shareCode: shareCode,
tripSnapshots: tripSnapshots,
createdAt: createdAt,
modifiedAt: modifiedAt
)
// Preserve the actual stored versions (not recomputed)
poll.tripVersions = tripVersions
return poll
}
}
// MARK: - CKPollVote
struct CKPollVote {
static let voteIdKey = "voteId"
static let pollIdKey = "pollId"
static let voterIdKey = "voterId"
static let rankingsKey = "rankings"
static let votedAtKey = "votedAt"
static let modifiedAtKey = "modifiedAt"
let record: CKRecord
init(record: CKRecord) {
self.record = record
}
init(vote: PollVote) {
let record = CKRecord(recordType: CKRecordType.pollVote, recordID: CKRecord.ID(recordName: vote.id.uuidString))
record[CKPollVote.voteIdKey] = vote.id.uuidString
record[CKPollVote.pollIdKey] = vote.pollId.uuidString
record[CKPollVote.voterIdKey] = vote.odg
record[CKPollVote.rankingsKey] = vote.rankings
record[CKPollVote.votedAtKey] = vote.votedAt
record[CKPollVote.modifiedAtKey] = vote.modifiedAt
self.record = record
}
func toVote() -> PollVote? {
guard let voteIdString = record[CKPollVote.voteIdKey] as? String,
let voteId = UUID(uuidString: voteIdString),
let pollIdString = record[CKPollVote.pollIdKey] as? String,
let pollId = UUID(uuidString: pollIdString),
let voterId = record[CKPollVote.voterIdKey] as? String,
let rankings = record[CKPollVote.rankingsKey] as? [Int],
let votedAt = record[CKPollVote.votedAtKey] as? Date,
let modifiedAt = record[CKPollVote.modifiedAtKey] as? Date
else { return nil }
return PollVote(
id: voteId,
pollId: pollId,
odg: voterId,
rankings: rankings,
votedAt: votedAt,
modifiedAt: modifiedAt
)
}
}

View File

@@ -0,0 +1,139 @@
//
// TripPoll.swift
// SportsTime
//
import Foundation
// MARK: - Trip Poll
struct TripPoll: Identifiable, Codable, Hashable {
let id: UUID
var title: String
let ownerId: String
let shareCode: String
var tripSnapshots: [Trip]
var tripVersions: [String]
let createdAt: Date
var modifiedAt: Date
init(
id: UUID = UUID(),
title: String,
ownerId: String,
shareCode: String = TripPoll.generateShareCode(),
tripSnapshots: [Trip],
createdAt: Date = Date(),
modifiedAt: Date = Date()
) {
self.id = id
self.title = title
self.ownerId = ownerId
self.shareCode = shareCode
self.tripSnapshots = tripSnapshots
self.tripVersions = tripSnapshots.map { TripPoll.computeTripHash($0) }
self.createdAt = createdAt
self.modifiedAt = modifiedAt
}
// MARK: - Share Code Generation
private static let shareCodeCharacters = Array("ABCDEFGHJKMNPQRSTUVWXYZ23456789")
static func generateShareCode() -> String {
String((0..<6).map { _ in shareCodeCharacters.randomElement()! })
}
// MARK: - Trip Hash
static func computeTripHash(_ trip: Trip) -> String {
var hasher = Hasher()
hasher.combine(trip.stops.map { $0.city })
hasher.combine(trip.stops.flatMap { $0.games })
hasher.combine(trip.preferences.startDate)
hasher.combine(trip.preferences.endDate)
return String(hasher.finalize())
}
// MARK: - Deep Link URL
var shareURL: URL {
URL(string: "sportstime://poll/\(shareCode)")!
}
}
// MARK: - Poll Vote
struct PollVote: Identifiable, Codable, Hashable {
let id: UUID
let pollId: UUID
let odg: String // voter's userRecordID
var rankings: [Int] // trip indices in preference order
let votedAt: Date
var modifiedAt: Date
init(
id: UUID = UUID(),
pollId: UUID,
odg: String,
rankings: [Int],
votedAt: Date = Date(),
modifiedAt: Date = Date()
) {
self.id = id
self.pollId = pollId
self.odg = odg
self.rankings = rankings
self.votedAt = votedAt
self.modifiedAt = modifiedAt
}
/// Calculate Borda count scores from rankings
/// Returns array where index = trip index, value = score
static func calculateScores(rankings: [Int], tripCount: Int) -> [Int] {
var scores = Array(repeating: 0, count: tripCount)
for (rank, tripIndex) in rankings.enumerated() {
guard tripIndex < tripCount else { continue }
let points = tripCount - rank
scores[tripIndex] = points
}
return scores
}
}
// MARK: - Poll Results
struct PollResults {
let poll: TripPoll
let votes: [PollVote]
var voterCount: Int { votes.count }
var tripScores: [(tripIndex: Int, score: Int)] {
guard !votes.isEmpty else {
return poll.tripSnapshots.indices.map { ($0, 0) }
}
var totalScores = Array(repeating: 0, count: poll.tripSnapshots.count)
for vote in votes {
let scores = PollVote.calculateScores(rankings: vote.rankings, tripCount: poll.tripSnapshots.count)
for (index, score) in scores.enumerated() {
totalScores[index] += score
}
}
return totalScores.enumerated()
.map { ($0.offset, $0.element) }
.sorted { $0.score > $1.score }
}
var maxScore: Int {
tripScores.first?.score ?? 0
}
func scorePercentage(for tripIndex: Int) -> Double {
guard maxScore > 0 else { return 0 }
let score = tripScores.first { $0.tripIndex == tripIndex }?.score ?? 0
return Double(score) / Double(maxScore)
}
}

View File

@@ -0,0 +1,134 @@
//
// LocalPoll.swift
// SportsTime
//
// SwiftData models for local poll persistence
//
import Foundation
import SwiftData
// MARK: - Local Trip Poll
@Model
final class LocalTripPoll {
@Attribute(.unique) var id: UUID
var title: String
var ownerId: String
var shareCode: String
var tripSnapshotsData: Data // Encoded [Trip]
var tripVersions: [String]
var createdAt: Date
var modifiedAt: Date
var lastSyncedAt: Date?
@Relationship(deleteRule: .cascade)
var votes: [LocalPollVote]?
init(
id: UUID = UUID(),
title: String,
ownerId: String,
shareCode: String,
tripSnapshotsData: Data,
tripVersions: [String],
createdAt: Date = Date(),
modifiedAt: Date = Date(),
lastSyncedAt: Date? = nil
) {
self.id = id
self.title = title
self.ownerId = ownerId
self.shareCode = shareCode
self.tripSnapshotsData = tripSnapshotsData
self.tripVersions = tripVersions
self.createdAt = createdAt
self.modifiedAt = modifiedAt
self.lastSyncedAt = lastSyncedAt
}
var tripSnapshots: [Trip] {
(try? JSONDecoder().decode([Trip].self, from: tripSnapshotsData)) ?? []
}
func toPoll() -> TripPoll {
var poll = TripPoll(
id: id,
title: title,
ownerId: ownerId,
shareCode: shareCode,
tripSnapshots: tripSnapshots,
createdAt: createdAt,
modifiedAt: modifiedAt
)
poll.tripVersions = tripVersions
return poll
}
static func from(_ poll: TripPoll) -> LocalTripPoll? {
guard let tripsData = try? JSONEncoder().encode(poll.tripSnapshots) else { return nil }
return LocalTripPoll(
id: poll.id,
title: poll.title,
ownerId: poll.ownerId,
shareCode: poll.shareCode,
tripSnapshotsData: tripsData,
tripVersions: poll.tripVersions,
createdAt: poll.createdAt,
modifiedAt: poll.modifiedAt
)
}
}
// MARK: - Local Poll Vote
@Model
final class LocalPollVote {
@Attribute(.unique) var id: UUID
var pollId: UUID
var voterId: String
var rankings: [Int]
var votedAt: Date
var modifiedAt: Date
var lastSyncedAt: Date?
init(
id: UUID = UUID(),
pollId: UUID,
voterId: String,
rankings: [Int],
votedAt: Date = Date(),
modifiedAt: Date = Date(),
lastSyncedAt: Date? = nil
) {
self.id = id
self.pollId = pollId
self.voterId = voterId
self.rankings = rankings
self.votedAt = votedAt
self.modifiedAt = modifiedAt
self.lastSyncedAt = lastSyncedAt
}
func toVote() -> PollVote {
PollVote(
id: id,
pollId: pollId,
odg: voterId,
rankings: rankings,
votedAt: votedAt,
modifiedAt: modifiedAt
)
}
static func from(_ vote: PollVote) -> LocalPollVote {
LocalPollVote(
id: vote.id,
pollId: vote.pollId,
voterId: vote.odg,
rankings: vote.rankings,
votedAt: vote.votedAt,
modifiedAt: vote.modifiedAt
)
}
}

View File

@@ -0,0 +1,362 @@
//
// PollService.swift
// SportsTime
//
// CloudKit service for trip polls and voting
//
import Foundation
import CloudKit
import SwiftData
// MARK: - Poll Errors
enum PollError: Error, LocalizedError {
case notSignedIn
case pollNotFound
case alreadyVoted
case notPollOwner
case networkUnavailable
case encodingError
case unknown(Error)
var errorDescription: String? {
switch self {
case .notSignedIn:
return "Please sign in to iCloud to use polls."
case .pollNotFound:
return "Poll not found. It may have been deleted."
case .alreadyVoted:
return "You have already voted on this poll."
case .notPollOwner:
return "Only the poll owner can perform this action."
case .networkUnavailable:
return "Unable to connect. Please check your internet connection."
case .encodingError:
return "Failed to save poll data."
case .unknown(let error):
return "An error occurred: \(error.localizedDescription)"
}
}
}
// MARK: - Poll Service
actor PollService {
static let shared = PollService()
private let container: CKContainer
private let publicDatabase: CKDatabase
private var currentUserRecordID: String?
private var pollSubscriptionID: CKSubscription.ID?
private init() {
self.container = CKContainer(identifier: "iCloud.com.sportstime.app")
self.publicDatabase = container.publicCloudDatabase
}
// MARK: - User Identity
func getCurrentUserRecordID() async throws -> String {
if let cached = currentUserRecordID {
return cached
}
let recordID = try await container.userRecordID()
currentUserRecordID = recordID.recordName
return recordID.recordName
}
func isSignedIn() async -> Bool {
do {
_ = try await getCurrentUserRecordID()
return true
} catch {
return false
}
}
// MARK: - Poll CRUD
func createPoll(_ poll: TripPoll) async throws -> TripPoll {
let ckPoll = CKTripPoll(poll: poll)
do {
try await publicDatabase.save(ckPoll.record)
return poll
} catch let error as CKError {
throw mapCloudKitError(error)
} catch {
throw PollError.unknown(error)
}
}
func fetchPoll(byShareCode shareCode: String) async throws -> TripPoll {
let predicate = NSPredicate(format: "%K == %@", CKTripPoll.shareCodeKey, shareCode.uppercased())
let query = CKQuery(recordType: CKRecordType.tripPoll, predicate: predicate)
do {
let (results, _) = try await publicDatabase.records(matching: query)
guard let result = results.first,
case .success(let record) = result.1,
let poll = CKTripPoll(record: record).toPoll()
else {
throw PollError.pollNotFound
}
return poll
} catch let error as CKError {
throw mapCloudKitError(error)
} catch let error as PollError {
throw error
} catch {
throw PollError.unknown(error)
}
}
func fetchPoll(byId pollId: UUID) async throws -> TripPoll {
let recordID = CKRecord.ID(recordName: pollId.uuidString)
do {
let record = try await publicDatabase.record(for: recordID)
guard let poll = CKTripPoll(record: record).toPoll() else {
throw PollError.pollNotFound
}
return poll
} catch let error as CKError {
if error.code == .unknownItem {
throw PollError.pollNotFound
}
throw mapCloudKitError(error)
} catch let error as PollError {
throw error
} catch {
throw PollError.unknown(error)
}
}
func fetchMyPolls() async throws -> [TripPoll] {
let userId = try await getCurrentUserRecordID()
let predicate = NSPredicate(format: "%K == %@", CKTripPoll.ownerIdKey, userId)
let query = CKQuery(recordType: CKRecordType.tripPoll, predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: CKTripPoll.createdAtKey, ascending: false)]
do {
let (results, _) = try await publicDatabase.records(matching: query)
return results.compactMap { result in
guard case .success(let record) = result.1 else { return nil }
return CKTripPoll(record: record).toPoll()
}
} catch let error as CKError {
throw mapCloudKitError(error)
} catch {
throw PollError.unknown(error)
}
}
func updatePoll(_ poll: TripPoll, resetVotes: Bool = false) async throws -> TripPoll {
let userId = try await getCurrentUserRecordID()
guard poll.ownerId == userId else {
throw PollError.notPollOwner
}
var updatedPoll = poll
updatedPoll.modifiedAt = Date()
let ckPoll = CKTripPoll(poll: updatedPoll)
do {
try await publicDatabase.save(ckPoll.record)
if resetVotes {
try await deleteVotes(forPollId: poll.id)
}
return updatedPoll
} catch let error as CKError {
throw mapCloudKitError(error)
} catch {
throw PollError.unknown(error)
}
}
func deletePoll(_ pollId: UUID) async throws {
let recordID = CKRecord.ID(recordName: pollId.uuidString)
do {
// Delete all votes first
try await deleteVotes(forPollId: pollId)
// Then delete the poll
try await publicDatabase.deleteRecord(withID: recordID)
} catch let error as CKError {
throw mapCloudKitError(error)
} catch {
throw PollError.unknown(error)
}
}
// MARK: - Voting
func submitVote(_ vote: PollVote) async throws -> PollVote {
// Check if user already voted
let existingVote = try await fetchMyVote(forPollId: vote.pollId)
if existingVote != nil {
throw PollError.alreadyVoted
}
let ckVote = CKPollVote(vote: vote)
do {
try await publicDatabase.save(ckVote.record)
return vote
} catch let error as CKError {
throw mapCloudKitError(error)
} catch {
throw PollError.unknown(error)
}
}
func updateVote(_ vote: PollVote) async throws -> PollVote {
let userId = try await getCurrentUserRecordID()
guard vote.odg == userId else {
throw PollError.notPollOwner // Using this error for now - voter can only update own vote
}
var updatedVote = vote
updatedVote.modifiedAt = Date()
let ckVote = CKPollVote(vote: updatedVote)
do {
try await publicDatabase.save(ckVote.record)
return updatedVote
} catch let error as CKError {
throw mapCloudKitError(error)
} catch {
throw PollError.unknown(error)
}
}
func fetchVotes(forPollId pollId: UUID) async throws -> [PollVote] {
let predicate = NSPredicate(format: "%K == %@", CKPollVote.pollIdKey, pollId.uuidString)
let query = CKQuery(recordType: CKRecordType.pollVote, predicate: predicate)
do {
let (results, _) = try await publicDatabase.records(matching: query)
return results.compactMap { result in
guard case .success(let record) = result.1 else { return nil }
return CKPollVote(record: record).toVote()
}
} catch let error as CKError {
throw mapCloudKitError(error)
} catch {
throw PollError.unknown(error)
}
}
func fetchMyVote(forPollId pollId: UUID) async throws -> PollVote? {
let userId = try await getCurrentUserRecordID()
let predicate = NSPredicate(
format: "%K == %@ AND %K == %@",
CKPollVote.pollIdKey, pollId.uuidString,
CKPollVote.voterIdKey, userId
)
let query = CKQuery(recordType: CKRecordType.pollVote, predicate: predicate)
do {
let (results, _) = try await publicDatabase.records(matching: query)
guard let result = results.first,
case .success(let record) = result.1
else {
return nil
}
return CKPollVote(record: record).toVote()
} catch let error as CKError {
throw mapCloudKitError(error)
} catch {
throw PollError.unknown(error)
}
}
private func deleteVotes(forPollId pollId: UUID) async throws {
let votes = try await fetchVotes(forPollId: pollId)
let recordIDs = votes.map { CKRecord.ID(recordName: $0.id.uuidString) }
if !recordIDs.isEmpty {
let result = try await publicDatabase.modifyRecords(saving: [], deleting: recordIDs)
// Ignore individual delete errors - votes may already be deleted
_ = result
}
}
// MARK: - Subscriptions
func subscribeToVoteUpdates(forPollId pollId: UUID) async throws {
let predicate = NSPredicate(format: "%K == %@", CKPollVote.pollIdKey, pollId.uuidString)
let subscription = CKQuerySubscription(
recordType: CKRecordType.pollVote,
predicate: predicate,
subscriptionID: "poll-votes-\(pollId.uuidString)",
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
do {
try await publicDatabase.save(subscription)
pollSubscriptionID = subscription.subscriptionID
} catch let error as CKError {
// Subscription already exists is OK
if error.code != .serverRejectedRequest {
throw mapCloudKitError(error)
}
} catch {
throw PollError.unknown(error)
}
}
func unsubscribeFromVoteUpdates() async throws {
guard let subscriptionID = pollSubscriptionID else { return }
do {
try await publicDatabase.deleteSubscription(withID: subscriptionID)
pollSubscriptionID = nil
} catch {
// Ignore errors - subscription may not exist
}
}
// MARK: - Results
func fetchPollResults(forPollId pollId: UUID) async throws -> PollResults {
async let pollTask = fetchPoll(byId: pollId)
async let votesTask = fetchVotes(forPollId: pollId)
let (poll, votes) = try await (pollTask, votesTask)
return PollResults(poll: poll, votes: votes)
}
// MARK: - Error Mapping
private func mapCloudKitError(_ error: CKError) -> PollError {
switch error.code {
case .notAuthenticated:
return .notSignedIn
case .networkUnavailable, .networkFailure:
return .networkUnavailable
case .unknownItem:
return .pollNotFound
default:
return .unknown(error)
}
}
}

View File

@@ -31,10 +31,31 @@ final class StoreManager {
private(set) var isLoading = false private(set) var isLoading = false
private(set) var error: StoreError? private(set) var error: StoreError?
// MARK: - Debug Override (DEBUG builds only)
#if DEBUG
private static let debugProOverrideKey = "debugProOverride"
/// When true, isPro returns true regardless of actual subscription status.
/// Defaults to true in debug builds. Only compiled in DEBUG configuration.
var debugProOverride: Bool {
get {
// Default to true by checking if key exists; object(forKey:) returns nil if not set
UserDefaults.standard.object(forKey: Self.debugProOverrideKey) as? Bool ?? true
}
set {
UserDefaults.standard.set(newValue, forKey: Self.debugProOverrideKey)
}
}
#endif
// MARK: - Computed Properties // MARK: - Computed Properties
var isPro: Bool { var isPro: Bool {
!purchasedProductIDs.intersection(Self.proProductIDs).isEmpty #if DEBUG
if debugProOverride { return true }
#endif
return !purchasedProductIDs.intersection(Self.proProductIDs).isEmpty
} }
var monthlyProduct: Product? { var monthlyProduct: Product? {

View File

@@ -432,51 +432,240 @@ struct SavedTripsListView: View {
let trips: [SavedTrip] let trips: [SavedTrip]
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@State private var polls: [TripPoll] = []
@State private var isLoadingPolls = false
@State private var showCreatePoll = false
@State private var selectedPoll: TripPoll?
/// Trips sorted by most cities (stops) first /// Trips sorted by most cities (stops) first
private var sortedTrips: [SavedTrip] { private var sortedTrips: [SavedTrip] {
trips.sorted { ($0.trip?.stops.count ?? 0) > ($1.trip?.stops.count ?? 0) } trips.sorted { ($0.trip?.stops.count ?? 0) > ($1.trip?.stops.count ?? 0) }
} }
/// Trips as domain objects for poll creation
private var tripsForPollCreation: [Trip] {
trips.compactMap { $0.trip }
}
var body: some View { var body: some View {
ScrollView { ScrollView {
LazyVStack(spacing: Theme.Spacing.lg) {
// Polls Section
pollsSection
// Trips Section
tripsSection
}
.padding(Theme.Spacing.md)
}
.themedBackground()
.navigationTitle("My Trips")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
Button {
showCreatePoll = true
} label: {
Label("Create Poll", systemImage: "chart.bar.doc.horizontal")
}
.disabled(trips.count < 2)
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.task {
await loadPolls()
}
.refreshable {
await loadPolls()
}
.sheet(isPresented: $showCreatePoll) {
PollCreationView(trips: tripsForPollCreation) { poll in
polls.insert(poll, at: 0)
}
}
.navigationDestination(for: TripPoll.self) { poll in
PollDetailView(pollId: poll.id)
}
}
// MARK: - Polls Section
@ViewBuilder
private var pollsSection: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Text("Group Polls")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
if trips.count >= 2 {
Button {
showCreatePoll = true
} label: {
Image(systemName: "plus.circle")
.foregroundStyle(Theme.warmOrange)
}
}
}
if isLoadingPolls {
ProgressView()
.frame(maxWidth: .infinity, alignment: .center)
.padding()
} else if polls.isEmpty {
emptyPollsCard
} else {
ForEach(polls) { poll in
NavigationLink(value: poll) {
PollRowCard(poll: poll)
}
.buttonStyle(.plain)
}
}
}
}
@ViewBuilder
private var emptyPollsCard: some View {
VStack(spacing: Theme.Spacing.sm) {
Image(systemName: "person.3")
.font(.title)
.foregroundStyle(Theme.textMuted(colorScheme))
Text("No group polls yet")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
if trips.count >= 2 {
Text("Create a poll to let friends vote on trip options")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
.multilineTextAlignment(.center)
} else {
Text("Save at least 2 trips to create a poll")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
.multilineTextAlignment(.center)
}
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
}
// MARK: - Trips Section
@ViewBuilder
private var tripsSection: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Saved Trips")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
if trips.isEmpty { if trips.isEmpty {
VStack(spacing: 16) { VStack(spacing: 16) {
Spacer()
.frame(height: 100)
Image(systemName: "suitcase") Image(systemName: "suitcase")
.font(.largeTitle) .font(.largeTitle)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Text("No Saved Trips") Text("No Saved Trips")
.font(.title2) .font(.headline)
.fontWeight(.semibold)
Text("Browse featured trips on the Home tab or create your own to get started.") Text("Browse featured trips on the Home tab or create your own to get started.")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, 40)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(Theme.Spacing.xl)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
} else { } else {
LazyVStack(spacing: Theme.Spacing.md) { ForEach(Array(sortedTrips.enumerated()), id: \.element.id) { index, savedTrip in
ForEach(Array(sortedTrips.enumerated()), id: \.element.id) { index, savedTrip in if let trip = savedTrip.trip {
if let trip = savedTrip.trip { NavigationLink {
NavigationLink { TripDetailView(trip: trip, games: savedTrip.games)
TripDetailView(trip: trip, games: savedTrip.games) } label: {
} label: { SavedTripListRow(trip: trip)
SavedTripListRow(trip: trip)
}
.buttonStyle(.plain)
.staggeredAnimation(index: index)
} }
.buttonStyle(.plain)
.staggeredAnimation(index: index)
} }
} }
.padding(Theme.Spacing.md)
} }
} }
.themedBackground() }
// MARK: - Actions
private func loadPolls() async {
isLoadingPolls = true
do {
polls = try await PollService.shared.fetchMyPolls()
} catch {
// Silently fail - polls just won't show
}
isLoadingPolls = false
}
}
// MARK: - Poll Row Card
private struct PollRowCard: View {
let poll: TripPoll
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: Theme.Spacing.md) {
// Icon
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.15))
.frame(width: 44, height: 44)
Image(systemName: "chart.bar.doc.horizontal")
.foregroundStyle(Theme.warmOrange)
}
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text(poll.title)
.font(.headline)
.foregroundStyle(Theme.textPrimary(colorScheme))
HStack(spacing: Theme.Spacing.sm) {
Label("\(poll.tripSnapshots.count) trips", systemImage: "map")
Text("")
Text(poll.shareCode)
.fontWeight(.semibold)
.foregroundStyle(Theme.warmOrange)
}
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
.shadow(color: Theme.cardShadow(colorScheme), radius: 6, y: 3)
} }
} }

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

View File

@@ -0,0 +1,133 @@
//
// PollCreationView.swift
// SportsTime
//
// View for creating a new trip poll
//
import SwiftUI
struct PollCreationView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@State private var viewModel = PollCreationViewModel()
let trips: [Trip]
var onPollCreated: ((TripPoll) -> Void)?
var body: some View {
NavigationStack {
Form {
Section {
TextField("Poll Title", text: $viewModel.title)
.textInputAutocapitalization(.words)
} header: {
Text("Title")
} footer: {
Text("Give your poll a name, like \"Summer Road Trip Options\"")
}
Section {
ForEach(trips) { trip in
TripSelectionRow(
trip: trip,
isSelected: viewModel.selectedTripIds.contains(trip.id)
) {
viewModel.toggleTrip(trip.id)
}
}
} header: {
Text("Select Trips (\(viewModel.selectedTripIds.count) selected)")
} footer: {
if let message = viewModel.validationMessage {
Text(message)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Create Poll")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Create") {
Task {
await viewModel.createPoll(trips: trips)
}
}
.disabled(!viewModel.canCreate || viewModel.isLoading)
}
}
.overlay {
if viewModel.isLoading {
ProgressView()
.scaleEffect(1.2)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.ultraThinMaterial)
}
}
.alert("Error", isPresented: .constant(viewModel.error != nil)) {
Button("OK") {
viewModel.error = nil
}
} message: {
if let error = viewModel.error {
Text(error.localizedDescription)
}
}
.onChange(of: viewModel.createdPoll) { _, newPoll in
if let poll = newPoll {
onPollCreated?(poll)
dismiss()
}
}
}
}
}
// MARK: - Trip Selection Row
private struct TripSelectionRow: View {
let trip: Trip
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(trip.name)
.font(.headline)
.foregroundStyle(.primary)
Text(tripSummary)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.title2)
.foregroundStyle(isSelected ? Theme.warmOrange : .secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private var tripSummary: String {
let stopCount = trip.stops.count
let gameCount = trip.stops.flatMap { $0.games }.count
return "\(stopCount) stops, \(gameCount) games"
}
}
#Preview {
PollCreationView(trips: [])
}

View File

@@ -0,0 +1,318 @@
//
// 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 isOwner = false
let pollId: UUID?
let shareCode: String?
init(pollId: UUID) {
self.pollId = pollId
self.shareCode = nil
}
init(shareCode: String) {
self.pollId = nil
self.shareCode = shareCode
}
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.")
}
}
@ViewBuilder
private func pollContent(_ poll: TripPoll) -> some View {
ScrollView {
VStack(spacing: 20) {
// Share Code Card
shareCodeCard(poll)
// Voting Status
votingStatusCard
// Results
if let results = viewModel.results {
resultsSection(results)
}
// Trip Previews
tripPreviewsSection(poll)
}
.padding()
}
}
@ViewBuilder
private func shareCodeCard(_ poll: TripPoll) -> some View {
VStack(spacing: 8) {
Text("Share Code")
.font(.caption)
.foregroundStyle(.secondary)
Text(poll.shareCode)
.font(.system(size: 32, weight: .bold, design: .monospaced))
.foregroundStyle(Theme.warmOrange)
Text("sportstime://poll/\(poll.shareCode)")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding()
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
@ViewBuilder
private var votingStatusCard: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(viewModel.hasVoted ? "You voted" : "You haven't voted yet")
.font(.headline)
Text("\(viewModel.votes.count) total votes")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Button(viewModel.hasVoted ? "Change Vote" : "Vote Now") {
showVotingSheet = true
}
.buttonStyle(.borderedProminent)
.tint(Theme.warmOrange)
}
.padding()
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
@ViewBuilder
private func resultsSection(_ results: PollResults) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Results")
.font(.headline)
ForEach(results.tripScores, id: \.tripIndex) { item in
let trip = results.poll.tripSnapshots[item.tripIndex]
ResultRow(
rank: results.tripScores.firstIndex { $0.tripIndex == item.tripIndex }! + 1,
tripName: trip.name,
score: item.score,
percentage: results.scorePercentage(for: item.tripIndex)
)
}
}
.padding()
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
@ViewBuilder
private func tripPreviewsSection(_ poll: TripPoll) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Trip Options")
.font(.headline)
ForEach(Array(poll.tripSnapshots.enumerated()), id: \.element.id) { index, trip in
TripPreviewCard(trip: trip, index: index + 1)
}
}
}
private func loadPoll() async {
if let pollId {
await viewModel.loadPoll(byId: pollId)
} else if let shareCode {
await viewModel.loadPoll(byShareCode: shareCode)
}
}
}
// MARK: - Result Row
private struct ResultRow: View {
let rank: Int
let tripName: String
let score: Int
let percentage: Double
var body: some View {
HStack(spacing: 12) {
Text("#\(rank)")
.font(.headline)
.foregroundStyle(rank == 1 ? Theme.warmOrange : .secondary)
.frame(width: 30)
VStack(alignment: .leading, spacing: 4) {
Text(tripName)
.font(.subheadline)
GeometryReader { geometry in
ZStack(alignment: .leading) {
Rectangle()
.fill(Color.secondary.opacity(0.2))
.frame(height: 8)
.clipShape(RoundedRectangle(cornerRadius: 4))
Rectangle()
.fill(rank == 1 ? Theme.warmOrange : Color.secondary)
.frame(width: geometry.size.width * percentage, height: 8)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
.frame(height: 8)
}
Text("\(score)")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 40, alignment: .trailing)
}
}
}
// MARK: - Trip Preview Card
private struct TripPreviewCard: View {
@Environment(\.colorScheme) private var colorScheme
let trip: Trip
let index: Int
var body: some View {
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.name)
.font(.headline)
}
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)
// Show cities
Text(trip.stops.map { $0.city }.joined(separator: ""))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.padding()
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
#Preview {
NavigationStack {
PollDetailView(shareCode: "ABC123")
}
}

View File

@@ -0,0 +1,178 @@
//
// PollVotingView.swift
// SportsTime
//
// View for ranking trips in a poll
//
import SwiftUI
struct PollVotingView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@State private var viewModel = PollVotingViewModel()
let poll: TripPoll
let existingVote: PollVote?
var onVoteSubmitted: (() -> Void)?
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Instructions
instructionsHeader
// Reorderable list
List {
ForEach(Array(viewModel.rankings.enumerated()), id: \.element) { index, tripIndex in
RankingRow(
rank: index + 1,
trip: poll.tripSnapshots[tripIndex]
)
}
.onMove { source, destination in
viewModel.moveTrip(from: source, to: destination)
}
}
.listStyle(.plain)
.environment(\.editMode, .constant(.active))
// Submit button
submitButton
}
.navigationTitle("Rank Trips")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
.onAppear {
viewModel.initializeRankings(
tripCount: poll.tripSnapshots.count,
existingVote: existingVote
)
}
.alert("Error", isPresented: .constant(viewModel.error != nil)) {
Button("OK") {
viewModel.error = nil
}
} message: {
if let error = viewModel.error {
Text(error.localizedDescription)
}
}
.onChange(of: viewModel.didSubmit) { _, didSubmit in
if didSubmit {
onVoteSubmitted?()
dismiss()
}
}
}
}
@ViewBuilder
private var instructionsHeader: some View {
VStack(spacing: 8) {
Image(systemName: "arrow.up.arrow.down")
.font(.title2)
.foregroundStyle(Theme.warmOrange)
Text("Drag to rank your preferences")
.font(.headline)
Text("Your top choice should be at the top")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity)
.background(Theme.cardBackground(colorScheme))
}
@ViewBuilder
private var submitButton: some View {
Button {
Task {
if let existingVote {
await viewModel.updateVote(existingVote: existingVote)
} else {
await viewModel.submitVote(pollId: poll.id)
}
}
} label: {
HStack {
if viewModel.isLoading {
ProgressView()
.tint(.white)
} else {
Text(existingVote != nil ? "Update Vote" : "Submit Vote")
}
}
.frame(maxWidth: .infinity)
.padding()
.background(Theme.warmOrange)
.foregroundStyle(.white)
.font(.headline)
}
.disabled(viewModel.isLoading || !viewModel.canSubmit)
}
}
// MARK: - Ranking Row
private struct RankingRow: View {
let rank: Int
let trip: Trip
var body: some View {
HStack(spacing: 12) {
// Rank badge
Text("\(rank)")
.font(.headline)
.foregroundStyle(.white)
.frame(width: 28, height: 28)
.background(rankColor)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(trip.name)
.font(.headline)
Text(tripSummary)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
}
private var rankColor: Color {
switch rank {
case 1: return Theme.warmOrange
case 2: return .blue
case 3: return .green
default: return .secondary
}
}
private var tripSummary: String {
let cities = trip.stops.map { $0.city }.joined(separator: "")
return cities
}
}
#Preview {
PollVotingView(
poll: TripPoll(
title: "Test Poll",
ownerId: "test",
tripSnapshots: []
),
existingVote: nil
)
}

View File

@@ -0,0 +1,152 @@
//
// 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 error: PollError?
@State private var showJoinPoll = false
@State private var joinCode = ""
var body: some View {
Group {
if isLoading && polls.isEmpty {
ProgressView("Loading polls...")
} else if polls.isEmpty {
emptyState
} else {
pollsList
}
}
.navigationTitle("Group Polls")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showJoinPoll = true
} label: {
Image(systemName: "link.badge.plus")
}
}
}
.refreshable {
await loadPolls()
}
.task {
await loadPolls()
}
.alert("Join Poll", isPresented: $showJoinPoll) {
TextField("Enter code", text: $joinCode)
.textInputAutocapitalization(.characters)
Button("Join") {
// Navigation will be handled by deep link
if !joinCode.isEmpty {
// TODO: Navigate to poll detail
}
}
Button("Cancel", role: .cancel) {
joinCode = ""
}
} message: {
Text("Enter the 6-character poll code")
}
.alert("Error", isPresented: .constant(error != nil)) {
Button("OK") {
error = nil
}
} message: {
if let error {
Text(error.localizedDescription)
}
}
}
@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)
}
}
}
.listStyle(.plain)
.navigationDestination(for: TripPoll.self) { poll in
PollDetailView(pollId: poll.id)
}
}
private func loadPolls() async {
isLoading = true
error = nil
do {
polls = try await PollService.shared.fetchMyPolls()
} catch let pollError as PollError {
error = pollError
} catch {
self.error = .unknown(error)
}
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()
}
}

View File

@@ -260,6 +260,13 @@ struct SettingsView: View {
#if DEBUG #if DEBUG
private var debugSection: some View { private var debugSection: some View {
Section { Section {
Toggle(isOn: Binding(
get: { StoreManager.shared.debugProOverride },
set: { StoreManager.shared.debugProOverride = $0 }
)) {
Label("Override Pro Status", systemImage: "star.fill")
}
Button { Button {
showOnboardingPaywall = true showOnboardingPaywall = true
} label: { } label: {

View File

@@ -35,6 +35,9 @@ struct SportsTimeApp: App {
VisitPhotoMetadata.self, VisitPhotoMetadata.self,
Achievement.self, Achievement.self,
CachedGameScore.self, CachedGameScore.self,
// Poll models
LocalTripPoll.self,
LocalPollVote.self,
// Canonical data models // Canonical data models
SyncState.self, SyncState.self,
CanonicalStadium.self, CanonicalStadium.self,
@@ -78,6 +81,7 @@ struct BootstrappedContentView: View {
@State private var bootstrapError: Error? @State private var bootstrapError: Error?
@State private var hasCompletedInitialSync = false @State private var hasCompletedInitialSync = false
@State private var showOnboardingPaywall = false @State private var showOnboardingPaywall = false
@State private var pollShareCode: String?
private var shouldShowOnboardingPaywall: Bool { private var shouldShowOnboardingPaywall: Bool {
!UserDefaults.standard.bool(forKey: "hasSeenOnboardingPaywall") && !StoreManager.shared.isPro !UserDefaults.standard.bool(forKey: "hasSeenOnboardingPaywall") && !StoreManager.shared.isPro
@@ -99,6 +103,11 @@ struct BootstrappedContentView: View {
OnboardingPaywallView(isPresented: $showOnboardingPaywall) OnboardingPaywallView(isPresented: $showOnboardingPaywall)
.interactiveDismissDisabled() .interactiveDismissDisabled()
} }
.sheet(item: $pollShareCode) { code in
NavigationStack {
PollDetailView(shareCode: code)
}
}
.onAppear { .onAppear {
if shouldShowOnboardingPaywall { if shouldShowOnboardingPaywall {
showOnboardingPaywall = true showOnboardingPaywall = true
@@ -109,6 +118,9 @@ struct BootstrappedContentView: View {
.task { .task {
await performBootstrap() await performBootstrap()
} }
.onOpenURL { url in
handleDeepLink(url)
}
.onChange(of: scenePhase) { _, newPhase in .onChange(of: scenePhase) { _, newPhase in
switch newPhase { switch newPhase {
case .active: case .active:
@@ -191,6 +203,25 @@ struct BootstrappedContentView: View {
print("Background sync error: \(error.localizedDescription)") print("Background sync error: \(error.localizedDescription)")
} }
} }
// MARK: - Deep Link Handling
private func handleDeepLink(_ url: URL) {
// Handle sportstime://poll/{code} deep links
guard url.scheme == "sportstime",
url.host == "poll",
let code = url.pathComponents.dropFirst().first,
code.count == 6
else { return }
pollShareCode = code.uppercased()
}
}
// MARK: - String Identifiable for Sheet
extension String: @retroactive Identifiable {
public var id: String { self }
} }
// MARK: - Bootstrap Loading View // MARK: - Bootstrap Loading View

View File

@@ -0,0 +1,309 @@
//
// PollTests.swift
// SportsTimeTests
//
// Tests for TripPoll, PollVote, and PollResults domain models
//
import Testing
@testable import SportsTime
import Foundation
// MARK: - TripPoll Tests
struct TripPollTests {
// MARK: - Share Code Tests
@Test("Share code has correct length")
func shareCode_HasCorrectLength() {
let code = TripPoll.generateShareCode()
#expect(code.count == 6)
}
@Test("Share code contains only allowed characters")
func shareCode_ContainsOnlyAllowedCharacters() {
let allowedCharacters = Set("ABCDEFGHJKMNPQRSTUVWXYZ23456789")
for _ in 0..<100 {
let code = TripPoll.generateShareCode()
for char in code {
#expect(allowedCharacters.contains(char), "Unexpected character: \(char)")
}
}
}
@Test("Share code excludes ambiguous characters")
func shareCode_ExcludesAmbiguousCharacters() {
let ambiguousCharacters = Set("0O1IL")
for _ in 0..<100 {
let code = TripPoll.generateShareCode()
for char in code {
#expect(!ambiguousCharacters.contains(char), "Found ambiguous character: \(char)")
}
}
}
@Test("Share codes are unique")
func shareCode_IsUnique() {
var codes = Set<String>()
for _ in 0..<1000 {
let code = TripPoll.generateShareCode()
codes.insert(code)
}
// With 6 chars from 32 possibilities, collisions in 1000 samples should be rare
#expect(codes.count >= 990, "Too many collisions in share code generation")
}
// MARK: - Share URL Tests
@Test("Share URL is correctly formatted")
func shareURL_IsCorrectlyFormatted() {
let poll = makeTestPoll(shareCode: "ABC123")
#expect(poll.shareURL.absoluteString == "sportstime://poll/ABC123")
}
// MARK: - Trip Hash Tests
@Test("Trip hash is deterministic")
func tripHash_IsDeterministic() {
let trip = makeTestTrip(cities: ["Chicago", "Detroit"])
let hash1 = TripPoll.computeTripHash(trip)
let hash2 = TripPoll.computeTripHash(trip)
#expect(hash1 == hash2)
}
@Test("Trip hash differs for different cities")
func tripHash_DiffersForDifferentCities() {
let trip1 = makeTestTrip(cities: ["Chicago", "Detroit"])
let trip2 = makeTestTrip(cities: ["Chicago", "Milwaukee"])
let hash1 = TripPoll.computeTripHash(trip1)
let hash2 = TripPoll.computeTripHash(trip2)
#expect(hash1 != hash2)
}
@Test("Trip hash differs for different dates")
func tripHash_DiffersForDifferentDates() {
let baseDate = Date()
let trip1 = makeTestTrip(cities: ["Chicago"], startDate: baseDate)
let trip2 = makeTestTrip(cities: ["Chicago"], startDate: baseDate.addingTimeInterval(86400))
let hash1 = TripPoll.computeTripHash(trip1)
let hash2 = TripPoll.computeTripHash(trip2)
#expect(hash1 != hash2)
}
// MARK: - Initialization Tests
@Test("Poll initializes with trip versions")
func poll_InitializesWithTripVersions() {
let trip1 = makeTestTrip(cities: ["Chicago"])
let trip2 = makeTestTrip(cities: ["Detroit"])
let poll = TripPoll(
title: "Test Poll",
ownerId: "user123",
tripSnapshots: [trip1, trip2]
)
#expect(poll.tripVersions.count == 2)
#expect(poll.tripVersions[0] == TripPoll.computeTripHash(trip1))
#expect(poll.tripVersions[1] == TripPoll.computeTripHash(trip2))
}
}
// MARK: - PollVote Tests
struct PollVoteTests {
// MARK: - Borda Count Tests
@Test("Borda count scores 3 trips correctly")
func bordaCount_Scores3TripsCorrectly() {
// Rankings: [2, 0, 1] means trip 2 is #1, trip 0 is #2, trip 1 is #3
let rankings = [2, 0, 1]
let scores = PollVote.calculateScores(rankings: rankings, tripCount: 3)
// Trip 2 is rank 0 (first place): 3 - 0 = 3 points
// Trip 0 is rank 1 (second place): 3 - 1 = 2 points
// Trip 1 is rank 2 (third place): 3 - 2 = 1 point
#expect(scores[0] == 2, "Trip 0 should have 2 points")
#expect(scores[1] == 1, "Trip 1 should have 1 point")
#expect(scores[2] == 3, "Trip 2 should have 3 points")
}
@Test("Borda count scores 2 trips correctly")
func bordaCount_Scores2TripsCorrectly() {
let rankings = [1, 0] // Trip 1 first, Trip 0 second
let scores = PollVote.calculateScores(rankings: rankings, tripCount: 2)
#expect(scores[0] == 1, "Trip 0 should have 1 point")
#expect(scores[1] == 2, "Trip 1 should have 2 points")
}
@Test("Borda count handles invalid trip index")
func bordaCount_HandlesInvalidTripIndex() {
let rankings = [0, 5] // 5 is out of bounds for tripCount 2
let scores = PollVote.calculateScores(rankings: rankings, tripCount: 2)
#expect(scores[0] == 2, "Trip 0 should have 2 points")
#expect(scores[1] == 0, "Trip 1 should have 0 points (never ranked)")
}
@Test("Borda count with 5 trips")
func bordaCount_With5Trips() {
// Rankings: trip indices in preference order
let rankings = [4, 2, 0, 3, 1] // Trip 4 is best, trip 1 is worst
let scores = PollVote.calculateScores(rankings: rankings, tripCount: 5)
// Points: 5 for 1st, 4 for 2nd, 3 for 3rd, 2 for 4th, 1 for 5th
#expect(scores[0] == 3, "Trip 0 (3rd place) should have 3 points")
#expect(scores[1] == 1, "Trip 1 (5th place) should have 1 point")
#expect(scores[2] == 4, "Trip 2 (2nd place) should have 4 points")
#expect(scores[3] == 2, "Trip 3 (4th place) should have 2 points")
#expect(scores[4] == 5, "Trip 4 (1st place) should have 5 points")
}
}
// MARK: - PollResults Tests
struct PollResultsTests {
@Test("Results with no votes returns zero scores")
func results_NoVotesReturnsZeroScores() {
let poll = makeTestPoll(tripCount: 3)
let results = PollResults(poll: poll, votes: [])
#expect(results.voterCount == 0)
#expect(results.maxScore == 0)
#expect(results.tripScores.count == 3)
for item in results.tripScores {
#expect(item.score == 0)
}
}
@Test("Results with single vote")
func results_SingleVote() {
let poll = makeTestPoll(tripCount: 3)
let vote = PollVote(pollId: poll.id, odg: "voter1", rankings: [2, 0, 1])
let results = PollResults(poll: poll, votes: [vote])
#expect(results.voterCount == 1)
#expect(results.maxScore == 3)
// Trip 2 should be first with 3 points
#expect(results.tripScores[0].tripIndex == 2)
#expect(results.tripScores[0].score == 3)
// Trip 0 should be second with 2 points
#expect(results.tripScores[1].tripIndex == 0)
#expect(results.tripScores[1].score == 2)
// Trip 1 should be third with 1 point
#expect(results.tripScores[2].tripIndex == 1)
#expect(results.tripScores[2].score == 1)
}
@Test("Results aggregates multiple votes")
func results_AggregatesMultipleVotes() {
let poll = makeTestPoll(tripCount: 3)
let vote1 = PollVote(pollId: poll.id, odg: "voter1", rankings: [0, 1, 2]) // Trip 0 first
let vote2 = PollVote(pollId: poll.id, odg: "voter2", rankings: [0, 2, 1]) // Trip 0 first
let vote3 = PollVote(pollId: poll.id, odg: "voter3", rankings: [1, 0, 2]) // Trip 1 first
let results = PollResults(poll: poll, votes: [vote1, vote2, vote3])
#expect(results.voterCount == 3)
// Trip 0: 3 + 3 + 2 = 8 points (first, first, second)
// Trip 1: 2 + 1 + 3 = 6 points (second, third, first)
// Trip 2: 1 + 2 + 1 = 4 points (third, second, third)
#expect(results.tripScores[0].tripIndex == 0, "Trip 0 should be ranked first")
#expect(results.tripScores[0].score == 8)
#expect(results.tripScores[1].tripIndex == 1, "Trip 1 should be ranked second")
#expect(results.tripScores[1].score == 6)
#expect(results.tripScores[2].tripIndex == 2, "Trip 2 should be ranked third")
#expect(results.tripScores[2].score == 4)
}
@Test("Score percentage calculation")
func results_ScorePercentageCalculation() {
let poll = makeTestPoll(tripCount: 2)
let vote1 = PollVote(pollId: poll.id, odg: "voter1", rankings: [0, 1])
let vote2 = PollVote(pollId: poll.id, odg: "voter2", rankings: [0, 1])
let results = PollResults(poll: poll, votes: [vote1, vote2])
// Trip 0: 2 + 2 = 4 points (max)
// Trip 1: 1 + 1 = 2 points
#expect(results.scorePercentage(for: 0) == 1.0, "Trip 0 should be 100%")
#expect(results.scorePercentage(for: 1) == 0.5, "Trip 1 should be 50%")
}
@Test("Score percentage returns zero when no votes")
func results_ScorePercentageReturnsZeroWhenNoVotes() {
let poll = makeTestPoll(tripCount: 2)
let results = PollResults(poll: poll, votes: [])
#expect(results.scorePercentage(for: 0) == 0)
#expect(results.scorePercentage(for: 1) == 0)
}
@Test("Results handles tie correctly")
func results_HandlesTieCorrectly() {
let poll = makeTestPoll(tripCount: 2)
let vote1 = PollVote(pollId: poll.id, odg: "voter1", rankings: [0, 1])
let vote2 = PollVote(pollId: poll.id, odg: "voter2", rankings: [1, 0])
let results = PollResults(poll: poll, votes: [vote1, vote2])
// Trip 0: 2 + 1 = 3 points
// Trip 1: 1 + 2 = 3 points
// Both tied at 3
#expect(results.tripScores[0].score == 3)
#expect(results.tripScores[1].score == 3)
#expect(results.maxScore == 3)
}
}
// MARK: - Test Helpers
private func makeTestTrip(
cities: [String],
startDate: Date = Date(),
games: [String] = []
) -> Trip {
let stops = cities.enumerated().map { index, city in
TripStop(
stopNumber: index + 1,
city: city,
state: "XX",
arrivalDate: startDate.addingTimeInterval(Double(index) * 86400),
departureDate: startDate.addingTimeInterval(Double(index + 1) * 86400),
games: games
)
}
return Trip(
name: "Test Trip",
preferences: TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: startDate,
endDate: startDate.addingTimeInterval(86400 * Double(cities.count))
),
stops: stops
)
}
private func makeTestPoll(tripCount: Int = 3, shareCode: String? = nil) -> TripPoll {
let trips = (0..<tripCount).map { index in
makeTestTrip(cities: ["City\(index)"])
}
return TripPoll(
title: "Test Poll",
ownerId: "testOwner",
shareCode: shareCode ?? TripPoll.generateShareCode(),
tripSnapshots: trips
)
}