From 13385b6562e6524e7c5b75acfe64354823d61d3c Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 13 Jan 2026 21:54:42 -0600 Subject: [PATCH] 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 --- .../Core/Models/CloudKit/CKModels.swift | 113 ++++++ SportsTime/Core/Models/Domain/TripPoll.swift | 139 +++++++ SportsTime/Core/Models/Local/LocalPoll.swift | 134 +++++++ SportsTime/Core/Services/PollService.swift | 362 ++++++++++++++++++ SportsTime/Core/Store/StoreManager.swift | 23 +- SportsTime/Features/Home/Views/HomeView.swift | 225 ++++++++++- .../ViewModels/PollCreationViewModel.swift | 77 ++++ .../ViewModels/PollDetailViewModel.swift | 144 +++++++ .../ViewModels/PollVotingViewModel.swift | 90 +++++ .../Polls/Views/PollCreationView.swift | 133 +++++++ .../Features/Polls/Views/PollDetailView.swift | 318 +++++++++++++++ .../Features/Polls/Views/PollVotingView.swift | 178 +++++++++ .../Features/Polls/Views/PollsListView.swift | 152 ++++++++ .../Settings/Views/SettingsView.swift | 7 + SportsTime/SportsTimeApp.swift | 31 ++ SportsTimeTests/Domain/PollTests.swift | 309 +++++++++++++++ 16 files changed, 2416 insertions(+), 19 deletions(-) create mode 100644 SportsTime/Core/Models/Domain/TripPoll.swift create mode 100644 SportsTime/Core/Models/Local/LocalPoll.swift create mode 100644 SportsTime/Core/Services/PollService.swift create mode 100644 SportsTime/Features/Polls/ViewModels/PollCreationViewModel.swift create mode 100644 SportsTime/Features/Polls/ViewModels/PollDetailViewModel.swift create mode 100644 SportsTime/Features/Polls/ViewModels/PollVotingViewModel.swift create mode 100644 SportsTime/Features/Polls/Views/PollCreationView.swift create mode 100644 SportsTime/Features/Polls/Views/PollDetailView.swift create mode 100644 SportsTime/Features/Polls/Views/PollVotingView.swift create mode 100644 SportsTime/Features/Polls/Views/PollsListView.swift create mode 100644 SportsTimeTests/Domain/PollTests.swift diff --git a/SportsTime/Core/Models/CloudKit/CKModels.swift b/SportsTime/Core/Models/CloudKit/CKModels.swift index 58e6c4f..30b6d0b 100644 --- a/SportsTime/Core/Models/CloudKit/CKModels.swift +++ b/SportsTime/Core/Models/CloudKit/CKModels.swift @@ -18,6 +18,8 @@ enum CKRecordType { static let leagueStructure = "LeagueStructure" static let teamAlias = "TeamAlias" static let stadiumAlias = "StadiumAlias" + static let tripPoll = "TripPoll" + static let pollVote = "PollVote" } // 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 + ) + } +} diff --git a/SportsTime/Core/Models/Domain/TripPoll.swift b/SportsTime/Core/Models/Domain/TripPoll.swift new file mode 100644 index 0000000..85b1610 --- /dev/null +++ b/SportsTime/Core/Models/Domain/TripPoll.swift @@ -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) + } +} diff --git a/SportsTime/Core/Models/Local/LocalPoll.swift b/SportsTime/Core/Models/Local/LocalPoll.swift new file mode 100644 index 0000000..9e0f780 --- /dev/null +++ b/SportsTime/Core/Models/Local/LocalPoll.swift @@ -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 + ) + } +} diff --git a/SportsTime/Core/Services/PollService.swift b/SportsTime/Core/Services/PollService.swift new file mode 100644 index 0000000..0da8cfa --- /dev/null +++ b/SportsTime/Core/Services/PollService.swift @@ -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) + } + } +} diff --git a/SportsTime/Core/Store/StoreManager.swift b/SportsTime/Core/Store/StoreManager.swift index 7c287d7..20a2792 100644 --- a/SportsTime/Core/Store/StoreManager.swift +++ b/SportsTime/Core/Store/StoreManager.swift @@ -31,10 +31,31 @@ final class StoreManager { private(set) var isLoading = false 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 var isPro: Bool { - !purchasedProductIDs.intersection(Self.proProductIDs).isEmpty + #if DEBUG + if debugProOverride { return true } + #endif + return !purchasedProductIDs.intersection(Self.proProductIDs).isEmpty } var monthlyProduct: Product? { diff --git a/SportsTime/Features/Home/Views/HomeView.swift b/SportsTime/Features/Home/Views/HomeView.swift index 4356f06..966f1f5 100644 --- a/SportsTime/Features/Home/Views/HomeView.swift +++ b/SportsTime/Features/Home/Views/HomeView.swift @@ -432,51 +432,240 @@ struct SavedTripsListView: View { let trips: [SavedTrip] @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 private var sortedTrips: [SavedTrip] { 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 { 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 { VStack(spacing: 16) { - Spacer() - .frame(height: 100) - Image(systemName: "suitcase") .font(.largeTitle) .foregroundColor(.secondary) Text("No Saved Trips") - .font(.title2) - .fontWeight(.semibold) + .font(.headline) Text("Browse featured trips on the Home tab or create your own to get started.") .font(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) - .padding(.horizontal, 40) } .frame(maxWidth: .infinity) + .padding(Theme.Spacing.xl) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) } else { - LazyVStack(spacing: Theme.Spacing.md) { - ForEach(Array(sortedTrips.enumerated()), id: \.element.id) { index, savedTrip in - if let trip = savedTrip.trip { - NavigationLink { - TripDetailView(trip: trip, games: savedTrip.games) - } label: { - SavedTripListRow(trip: trip) - } - .buttonStyle(.plain) - .staggeredAnimation(index: index) + ForEach(Array(sortedTrips.enumerated()), id: \.element.id) { index, savedTrip in + if let trip = savedTrip.trip { + NavigationLink { + TripDetailView(trip: trip, games: savedTrip.games) + } label: { + SavedTripListRow(trip: trip) } + .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) } } diff --git a/SportsTime/Features/Polls/ViewModels/PollCreationViewModel.swift b/SportsTime/Features/Polls/ViewModels/PollCreationViewModel.swift new file mode 100644 index 0000000..adeb753 --- /dev/null +++ b/SportsTime/Features/Polls/ViewModels/PollCreationViewModel.swift @@ -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 = [] + 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 + } +} diff --git a/SportsTime/Features/Polls/ViewModels/PollDetailViewModel.swift b/SportsTime/Features/Polls/ViewModels/PollDetailViewModel.swift new file mode 100644 index 0000000..6547b5e --- /dev/null +++ b/SportsTime/Features/Polls/ViewModels/PollDetailViewModel.swift @@ -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() + } +} diff --git a/SportsTime/Features/Polls/ViewModels/PollVotingViewModel.swift b/SportsTime/Features/Polls/ViewModels/PollVotingViewModel.swift new file mode 100644 index 0000000..5bc972e --- /dev/null +++ b/SportsTime/Features/Polls/ViewModels/PollVotingViewModel.swift @@ -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.. 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: []) +} diff --git a/SportsTime/Features/Polls/Views/PollDetailView.swift b/SportsTime/Features/Polls/Views/PollDetailView.swift new file mode 100644 index 0000000..252fc9f --- /dev/null +++ b/SportsTime/Features/Polls/Views/PollDetailView.swift @@ -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") + } +} diff --git a/SportsTime/Features/Polls/Views/PollVotingView.swift b/SportsTime/Features/Polls/Views/PollVotingView.swift new file mode 100644 index 0000000..589dedd --- /dev/null +++ b/SportsTime/Features/Polls/Views/PollVotingView.swift @@ -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 + ) +} diff --git a/SportsTime/Features/Polls/Views/PollsListView.swift b/SportsTime/Features/Polls/Views/PollsListView.swift new file mode 100644 index 0000000..8847e27 --- /dev/null +++ b/SportsTime/Features/Polls/Views/PollsListView.swift @@ -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() + } +} diff --git a/SportsTime/Features/Settings/Views/SettingsView.swift b/SportsTime/Features/Settings/Views/SettingsView.swift index 1bd8166..eb64b7f 100644 --- a/SportsTime/Features/Settings/Views/SettingsView.swift +++ b/SportsTime/Features/Settings/Views/SettingsView.swift @@ -260,6 +260,13 @@ struct SettingsView: View { #if DEBUG private var debugSection: some View { Section { + Toggle(isOn: Binding( + get: { StoreManager.shared.debugProOverride }, + set: { StoreManager.shared.debugProOverride = $0 } + )) { + Label("Override Pro Status", systemImage: "star.fill") + } + Button { showOnboardingPaywall = true } label: { diff --git a/SportsTime/SportsTimeApp.swift b/SportsTime/SportsTimeApp.swift index 0dcb844..aebf59b 100644 --- a/SportsTime/SportsTimeApp.swift +++ b/SportsTime/SportsTimeApp.swift @@ -35,6 +35,9 @@ struct SportsTimeApp: App { VisitPhotoMetadata.self, Achievement.self, CachedGameScore.self, + // Poll models + LocalTripPoll.self, + LocalPollVote.self, // Canonical data models SyncState.self, CanonicalStadium.self, @@ -78,6 +81,7 @@ struct BootstrappedContentView: View { @State private var bootstrapError: Error? @State private var hasCompletedInitialSync = false @State private var showOnboardingPaywall = false + @State private var pollShareCode: String? private var shouldShowOnboardingPaywall: Bool { !UserDefaults.standard.bool(forKey: "hasSeenOnboardingPaywall") && !StoreManager.shared.isPro @@ -99,6 +103,11 @@ struct BootstrappedContentView: View { OnboardingPaywallView(isPresented: $showOnboardingPaywall) .interactiveDismissDisabled() } + .sheet(item: $pollShareCode) { code in + NavigationStack { + PollDetailView(shareCode: code) + } + } .onAppear { if shouldShowOnboardingPaywall { showOnboardingPaywall = true @@ -109,6 +118,9 @@ struct BootstrappedContentView: View { .task { await performBootstrap() } + .onOpenURL { url in + handleDeepLink(url) + } .onChange(of: scenePhase) { _, newPhase in switch newPhase { case .active: @@ -191,6 +203,25 @@ struct BootstrappedContentView: View { 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 diff --git a/SportsTimeTests/Domain/PollTests.swift b/SportsTimeTests/Domain/PollTests.swift new file mode 100644 index 0000000..2b0fd4a --- /dev/null +++ b/SportsTimeTests/Domain/PollTests.swift @@ -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() + 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..