fix: multiple bug fixes and improvements

- Fix suggested trips showing wrong sports for cross-country trips
- Remove quick start sections from home variants (Classic, Spotify)
- Remove dead quickActions code from HomeView
- Fix pace capsule animation in TripCreationView
- Add text wrapping to achievement descriptions
- Improve poll parsing with better error handling
- Various sharing system improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-14 09:35:18 -06:00
parent fe36f99bca
commit d034ee8612
22 changed files with 422 additions and 242 deletions

View File

@@ -510,17 +510,49 @@ struct CKTripPoll {
}
func toPoll() -> TripPoll? {
// Required fields with explicit guards for better debugging
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 }
let pollId = UUID(uuidString: pollIdString)
else {
print("CKTripPoll.toPoll: Failed to parse pollId")
return nil
}
guard let title = record[CKTripPoll.titleKey] as? String else {
print("CKTripPoll.toPoll: Missing title for poll \(pollId)")
return nil
}
guard let ownerId = record[CKTripPoll.ownerIdKey] as? String else {
print("CKTripPoll.toPoll: Missing ownerId for poll \(pollId)")
return nil
}
guard let shareCode = record[CKTripPoll.shareCodeKey] as? String else {
print("CKTripPoll.toPoll: Missing shareCode for poll \(pollId)")
return nil
}
guard let createdAt = record[CKTripPoll.createdAtKey] as? Date else {
print("CKTripPoll.toPoll: Missing createdAt for poll \(pollId)")
return nil
}
// modifiedAt defaults to createdAt if missing
let modifiedAt = record[CKTripPoll.modifiedAtKey] as? Date ?? createdAt
// Decode trip snapshots - this is the most likely failure point
var tripSnapshots: [Trip] = []
if let tripsData = record[CKTripPoll.tripSnapshotsKey] as? Data {
do {
tripSnapshots = try JSONDecoder().decode([Trip].self, from: tripsData)
} catch {
print("CKTripPoll.toPoll: Failed to decode tripSnapshots for poll \(pollId): \(error)")
// Return poll with empty trips rather than failing completely
}
} else {
print("CKTripPoll.toPoll: Missing tripSnapshots data for poll \(pollId)")
}
var poll = TripPoll(
id: pollId,
@@ -531,8 +563,13 @@ struct CKTripPoll {
createdAt: createdAt,
modifiedAt: modifiedAt
)
// Preserve the actual stored versions (not recomputed)
poll.tripVersions = tripVersions
// tripVersions is optional - it's recomputed if missing
if let storedVersions = record[CKTripPoll.tripVersionsKey] as? [String] {
poll.tripVersions = storedVersions
}
// Otherwise, keep the computed versions from init
return poll
}
}

View File

@@ -184,6 +184,13 @@ actor PollService {
}
func deletePoll(_ pollId: UUID) async throws {
// Verify ownership before deleting
let poll = try await fetchPoll(byId: pollId)
let userId = try await getCurrentUserRecordID()
guard poll.ownerId == userId else {
throw PollError.notPollOwner
}
let recordID = CKRecord.ID(recordName: pollId.uuidString)
do {
@@ -193,6 +200,8 @@ actor PollService {
try await publicDatabase.deleteRecord(withID: recordID)
} catch let error as CKError {
throw mapCloudKitError(error)
} catch let error as PollError {
throw error
} catch {
throw PollError.unknown(error)
}

View File

@@ -484,13 +484,17 @@ final class SuggestedTripsGenerator {
// Build richGames dictionary
let richGames = buildRichGames(from: selectedGames, teams: teams, stadiums: stadiums)
// Compute sports from games actually in the trip (not all selectedGames)
let gameIdsInTrip = Set(trip.stops.flatMap { $0.games })
let actualSports = Set(gameIdsInTrip.compactMap { richGames[$0]?.game.sport })
return SuggestedTrip(
id: UUID(),
region: .crossCountry,
isSingleSport: sports.count == 1,
isSingleSport: actualSports.count == 1,
trip: trip,
richGames: richGames,
sports: sports
sports: actualSports.isEmpty ? sports : actualSports
)
}

View File

@@ -466,7 +466,7 @@ final class PDFGenerator {
.font: UIFont.systemFont(ofSize: 11),
.foregroundColor: UIColor.darkGray
]
let venueText = "\(richGame.stadium.name) | \(richGame.game.gameTime)"
let venueText = "\(richGame.stadium.name) | \(richGame.localGameTimeShort)"
(venueText as NSString).draw(
at: CGPoint(x: margin + 110, y: currentY + 30),
withAttributes: venueAttributes

View File

@@ -137,6 +137,8 @@ private struct AchievementSpotlightView: View {
Text(achievement.definition.name)
.font(.system(size: 56, weight: .bold, design: .rounded))
.foregroundStyle(theme.textColor)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
// Description
Text(achievement.definition.description)
@@ -188,7 +190,7 @@ private struct AchievementCollectionView: View {
VStack(spacing: 40) {
// Header
Text("My \(year) Achievements")
Text("My \(String(year)) Achievements")
.font(.system(size: 48, weight: .bold, design: .rounded))
.foregroundStyle(theme.textColor)
@@ -271,6 +273,8 @@ private struct AchievementMilestoneView: View {
Text(achievement.definition.name)
.font(.system(size: 56, weight: .bold, design: .rounded))
.foregroundStyle(theme.textColor)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
// Description
Text(achievement.definition.description)

View File

@@ -13,7 +13,6 @@ import UIKit
struct ProgressShareContent: ShareableContent {
let progress: LeagueProgress
let tripCount: Int
let username: String?
var cardType: ShareCardType { .stadiumProgress }
@@ -29,7 +28,6 @@ struct ProgressShareContent: ShareableContent {
let cardView = ProgressCardView(
progress: progress,
tripCount: tripCount,
username: username,
theme: theme,
mapSnapshot: mapSnapshot
)
@@ -50,7 +48,6 @@ struct ProgressShareContent: ShareableContent {
private struct ProgressCardView: View {
let progress: LeagueProgress
let tripCount: Int
let username: String?
let theme: ShareTheme
let mapSnapshot: UIImage?
@@ -103,7 +100,7 @@ private struct ProgressCardView: View {
Spacer()
ShareCardFooter(theme: theme, username: username)
ShareCardFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}

View File

@@ -56,20 +56,9 @@ struct ShareCardHeader: View {
struct ShareCardFooter: View {
let theme: ShareTheme
var username: String? = nil
var body: some View {
VStack(spacing: 12) {
if let username = username, !username.isEmpty {
HStack(spacing: 8) {
Image(systemName: "person.circle.fill")
.font(.system(size: 24))
Text("@\(username)")
.font(.system(size: 28, weight: .medium))
}
.foregroundStyle(theme.secondaryTextColor)
}
HStack(spacing: 8) {
Image(systemName: "sportscourt.fill")
.font(.system(size: 20))

View File

@@ -57,8 +57,8 @@ extension ShareButton where Content == TripShareContent {
}
extension ShareButton where Content == ProgressShareContent {
init(progress: LeagueProgress, tripCount: Int = 0, username: String? = nil, style: ShareButtonStyle = .icon) {
self.content = ProgressShareContent(progress: progress, tripCount: tripCount, username: username)
init(progress: LeagueProgress, tripCount: Int = 0, style: ShareButtonStyle = .icon) {
self.content = ProgressShareContent(progress: progress, tripCount: tripCount)
self.style = style
}
}

View File

@@ -19,10 +19,6 @@ struct SharePreviewView<Content: ShareableContent>: View {
@State private var error: String?
@State private var showCopiedToast = false
// Progress-specific options
@State private var includeUsername = true
@State private var username = ""
init(content: Content) {
self.content = content
_selectedTheme = State(initialValue: ShareThemePreferences.theme(for: content.cardType))
@@ -38,11 +34,6 @@ struct SharePreviewView<Content: ShareableContent>: View {
// Theme selector
themeSelector
// Username toggle (progress cards only)
if content.cardType == .stadiumProgress {
usernameSection
}
// Action buttons
actionButtons
}
@@ -158,32 +149,6 @@ struct SharePreviewView<Content: ShareableContent>: View {
.buttonStyle(.plain)
}
// MARK: - Username Section
private var usernameSection: some View {
VStack(spacing: Theme.Spacing.sm) {
Toggle(isOn: $includeUsername) {
Text("Include username")
}
.onChange(of: includeUsername) { _, _ in
Task { await generatePreview() }
}
if includeUsername {
TextField("@username", text: $username)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.onChange(of: username) { _, _ in
Task { await generatePreview() }
}
}
}
.padding()
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
// MARK: - Action Buttons
private var actionButtons: some View {
@@ -204,39 +169,21 @@ struct SharePreviewView<Content: ShareableContent>: View {
}
.disabled(generatedImage == nil)
HStack(spacing: Theme.Spacing.sm) {
// Copy Image
Button {
copyImage()
} label: {
HStack {
Image(systemName: "doc.on.doc")
Text("Copy")
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.foregroundStyle(Theme.textPrimary(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
// Copy Image
Button {
copyImage()
} label: {
HStack {
Image(systemName: "doc.on.doc")
Text("Copy to Clipboard")
}
.disabled(generatedImage == nil)
// More Options
Button {
showSystemShare()
} label: {
HStack {
Image(systemName: "ellipsis.circle")
Text("More")
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.foregroundStyle(Theme.textPrimary(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.disabled(generatedImage == nil)
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.foregroundStyle(Theme.textPrimary(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.disabled(generatedImage == nil)
}
}
@@ -264,18 +211,7 @@ struct SharePreviewView<Content: ShareableContent>: View {
isGenerating = true
do {
// For progress content, we may need to inject username
if let progressContent = content as? ProgressShareContent {
// This is a workaround - ideally we'd have a more elegant solution
let modifiedContent = ProgressShareContent(
progress: progressContent.progress,
tripCount: progressContent.tripCount,
username: includeUsername ? (username.isEmpty ? nil : username) : nil
)
generatedImage = try await modifiedContent.render(theme: selectedTheme)
} else {
generatedImage = try await content.render(theme: selectedTheme)
}
generatedImage = try await content.render(theme: selectedTheme)
} catch {
self.error = error.localizedDescription
}

View File

@@ -157,29 +157,6 @@ struct HomeView: View {
.shadow(color: Theme.cardShadow(colorScheme), radius: 15, y: 8)
}
// MARK: - Quick Actions
private var quickActions: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Quick Start")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
SportSelectorGrid { sport in
selectedSport = sport
showNewTrip = true
}
.padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, 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)
}
}
}
// MARK: - Suggested Trips
@ViewBuilder

View File

@@ -28,10 +28,6 @@ struct HomeContent_Classic: View {
.padding(.horizontal, Theme.Spacing.md)
.padding(.top, Theme.Spacing.sm)
// Quick Actions
quickActions
.padding(.horizontal, Theme.Spacing.md)
// Suggested Trips
suggestedTripsSection
.padding(.horizontal, Theme.Spacing.md)
@@ -97,28 +93,6 @@ struct HomeContent_Classic: View {
.shadow(color: Theme.cardShadow(colorScheme), radius: 15, y: 8)
}
// MARK: - Quick Actions
private var quickActions: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Quick Start")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
SportSelectorGrid { _ in
showNewTrip = true
}
.padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, 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)
}
}
}
// MARK: - Suggested Trips
@ViewBuilder

View File

@@ -36,10 +36,6 @@ struct HomeContent_Spotify: View {
.padding(.horizontal, 16)
.padding(.top, 12)
// Quick actions
quickActions
.padding(.horizontal, 16)
// Your trips
if !savedTrips.isEmpty {
yourTripsSection
@@ -91,62 +87,6 @@ struct HomeContent_Spotify: View {
}
}
// MARK: - Quick Actions
private var quickActions: some View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
// New trip button
quickActionButton(title: "Plan Trip", icon: "plus.circle.fill", isPrimary: true) {
showNewTrip = true
}
// Saved trips button
if !savedTrips.isEmpty {
quickActionButton(title: "Your Trips", icon: "folder.fill", isPrimary: false) {
selectedTab = 2
}
}
// First saved trip quick access
if let firstTrip = savedTrips.first?.trip {
quickActionButton(title: firstTrip.name, icon: "play.circle.fill", isPrimary: false) {
// Could navigate to trip
}
}
// Refresh suggestions
quickActionButton(title: "Refresh", icon: "arrow.clockwise", isPrimary: false) {
Task {
await suggestedTripsGenerator.refreshTrips()
}
}
}
}
private func quickActionButton(title: String, icon: String, isPrimary: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 10) {
Image(systemName: icon)
.font(.system(size: 22))
.foregroundStyle(isPrimary ? spotifyGreen : textPrimary)
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(textPrimary)
.lineLimit(1)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(cardBg)
)
}
.buttonStyle(.plain)
}
// MARK: - Your Trips Section
private var yourTripsSection: some View {

View File

@@ -25,15 +25,19 @@ final class PollDetailViewModel {
return PollResults(poll: poll, votes: votes)
}
func checkIsOwner() async -> Bool {
guard let poll else { return false }
do {
let userId = try await pollService.getCurrentUserRecordID()
return poll.ownerId == userId
} catch {
return false
}
}
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
}
await checkIsOwner()
}
}
@@ -98,6 +102,32 @@ final class PollDetailViewModel {
isLoading = false
}
/// Load poll from an existing object (avoids CloudKit fetch delay)
func loadPoll(from existingPoll: TripPoll) async {
isLoading = true
error = nil
self.poll = existingPoll
do {
// Still fetch votes and subscription from CloudKit
async let votesTask = pollService.fetchVotes(forPollId: existingPoll.id)
async let myVoteTask = pollService.fetchMyVote(forPollId: existingPoll.id)
let (fetchedVotes, fetchedMyVote) = try await (votesTask, myVoteTask)
self.votes = fetchedVotes
self.myVote = fetchedMyVote
// Subscribe to vote updates
try? await pollService.subscribeToVoteUpdates(forPollId: existingPoll.id)
} catch {
// Votes fetch failed, but we have the poll - non-critical error
self.votes = []
}
isLoading = false
}
func refresh() async {
guard let poll else { return }

View File

@@ -18,15 +18,25 @@ struct PollDetailView: View {
let pollId: UUID?
let shareCode: String?
let initialPoll: TripPoll?
init(pollId: UUID) {
self.pollId = pollId
self.shareCode = nil
self.initialPoll = nil
}
init(shareCode: String) {
self.pollId = nil
self.shareCode = shareCode
self.initialPoll = nil
}
/// Initialize with a poll object directly (avoids fetch delay)
init(poll: TripPoll) {
self.pollId = poll.id
self.shareCode = nil
self.initialPoll = poll
}
var body: some View {
@@ -217,7 +227,10 @@ struct PollDetailView: View {
}
private func loadPoll() async {
if let pollId {
// If we have an initial poll object, use it directly to avoid CloudKit fetch delay
if let initialPoll {
await viewModel.loadPoll(from: initialPoll)
} else if let pollId {
await viewModel.loadPoll(byId: pollId)
} else if let shareCode {
await viewModel.loadPoll(byShareCode: shareCode)
@@ -291,6 +304,15 @@ private struct TripPreviewCard: View {
.font(.headline)
}
// Date range and duration
HStack {
Label(trip.formattedDateRange, systemImage: "calendar")
Spacer()
Label("\(trip.tripDuration) days", systemImage: "clock")
}
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Label("\(trip.stops.count) stops", systemImage: "mappin.and.ellipse")
Spacer()
@@ -303,7 +325,7 @@ private struct TripPreviewCard: View {
Text(trip.stops.map { $0.city }.joined(separator: ""))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.lineLimit(2)
}
.padding()
.background(Theme.cardBackground(colorScheme))

View File

@@ -11,6 +11,7 @@ struct PollsListView: View {
@Environment(\.colorScheme) private var colorScheme
@State private var polls: [TripPoll] = []
@State private var isLoading = false
@State private var hasLoadedInitially = false
@State private var error: PollError?
@State private var showJoinPoll = false
@State private var joinCode = ""
@@ -39,6 +40,8 @@ struct PollsListView: View {
await loadPolls()
}
.task {
guard !hasLoadedInitially else { return }
hasLoadedInitially = true
await loadPolls()
}
.alert("Join Poll", isPresented: $showJoinPoll) {
@@ -87,7 +90,7 @@ struct PollsListView: View {
}
.listStyle(.plain)
.navigationDestination(for: TripPoll.self) { poll in
PollDetailView(pollId: poll.id)
PollDetailView(poll: poll)
}
}

View File

@@ -492,11 +492,14 @@ struct AchievementDetailSheet: View {
.font(.title2)
.fontWeight(achievement.isEarned ? .bold : .regular)
.foregroundStyle(achievement.isEarned ? completedGold : Theme.textPrimary(colorScheme))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Text(achievement.definition.description)
.font(.body)
.foregroundStyle(Theme.textSecondary(colorScheme))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
// Category badge
HStack(spacing: 4) {

View File

@@ -269,7 +269,7 @@ struct GameRowView: View {
// Game info
HStack(spacing: 12) {
Label(game.game.gameTime, systemImage: "clock")
Label(game.localGameTimeShort, systemImage: "clock")
Label(game.stadium.name, systemImage: "building.2")
if let broadcast = game.game.broadcastInfo {

View File

@@ -1335,7 +1335,7 @@ struct GameCalendarRow: View {
.foregroundStyle(Theme.textPrimary(colorScheme))
HStack(spacing: Theme.Spacing.xs) {
Label(game.game.gameTime, systemImage: "clock")
Label(game.localGameTimeShort, systemImage: "clock")
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
@@ -1838,9 +1838,7 @@ struct TripOptionsView: View {
Menu {
ForEach(TripPaceFilter.allCases) { pace in
Button {
withAnimation(.easeInOut(duration: 0.2)) {
paceFilter = pace
}
paceFilter = pace
} label: {
Label(pace.rawValue, systemImage: pace.icon)
}
@@ -1865,7 +1863,6 @@ struct TripOptionsView: View {
Capsule()
.strokeBorder(paceFilter == .all ? Theme.textMuted(colorScheme).opacity(0.2) : Theme.warmOrange.opacity(0.3), lineWidth: 1)
)
.animation(nil, value: paceFilter)
}
}

View File

@@ -689,7 +689,7 @@ struct GameRow: View {
Spacer()
// Time
Text(game.game.gameTime)
Text(game.localGameTimeShort)
.font(.subheadline)
.foregroundStyle(Theme.warmOrange)
}