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:
105
SportsTime.xcodeproj/xcshareddata/xcschemes/SportsTime.xcscheme
Normal file
105
SportsTime.xcodeproj/xcshareddata/xcschemes/SportsTime.xcscheme
Normal file
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1CA7F8F22F0D647100490ABD"
|
||||
BuildableName = "SportsTime.app"
|
||||
BlueprintName = "SportsTime"
|
||||
ReferencedContainer = "container:SportsTime.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1CA7F9032F0D647300490ABD"
|
||||
BuildableName = "SportsTimeTests.xctest"
|
||||
BlueprintName = "SportsTimeTests"
|
||||
ReferencedContainer = "container:SportsTime.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1CA7F90D2F0D647400490ABD"
|
||||
BuildableName = "SportsTimeUITests.xctest"
|
||||
BlueprintName = "SportsTimeUITests"
|
||||
ReferencedContainer = "container:SportsTime.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1CA7F8F22F0D647100490ABD"
|
||||
BuildableName = "SportsTime.app"
|
||||
BlueprintName = "SportsTime"
|
||||
ReferencedContainer = "container:SportsTime.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../SportsTime/Configuration.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1CA7F8F22F0D647100490ABD"
|
||||
BuildableName = "SportsTime.app"
|
||||
BlueprintName = "SportsTime"
|
||||
ReferencedContainer = "container:SportsTime.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -689,7 +689,7 @@ struct GameRow: View {
|
||||
Spacer()
|
||||
|
||||
// Time
|
||||
Text(game.game.gameTime)
|
||||
Text(game.localGameTimeShort)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
|
||||
29
TO-DOS.md
29
TO-DOS.md
@@ -23,12 +23,31 @@ read docs/TEST_PLAN.md in full
|
||||
|
||||
question: do we need sync schedules anymore in settings
|
||||
|
||||
// things that are new
|
||||
// new dev
|
||||
- Notification reminders - "Your trip starts in 3 days"
|
||||
|
||||
// enhancements
|
||||
- Dark/light mode toggle along with system
|
||||
- Stadium notes - let users add personal notes/tips to stadiums they've visited
|
||||
sharing needs to completely overhauled. should be able to share a trip summary, achievements, progress. social media first
|
||||
- Achievements share says 2,026. Should be an option when share is hit of year or all time
|
||||
- if viewing a single sport share should share details regarding that sport only
|
||||
- need achievements for every supported league (how does this work with adding new sports in backed, might have to push with app update so if not achievements exist for that sport don’t show it as an option)
|
||||
- Share needs more styling, similar to onboarding
|
||||
- Ready to plan your trip has a summary, missing details should be in red
|
||||
|
||||
// new ish to existing features
|
||||
|
||||
// bugs
|
||||
Issue: sharing looks really dumb. need to be able to share achievements, league progress, and a trip
|
||||
Issue: fucking game show at 7 am ... the fuck?
|
||||
Issue: all all trips view when choosing "packed" "moderate" "relaxed" the capsule the option is in does a weird animation that looks off.
|
||||
- fucking game show at 7 am ... the fuck?
|
||||
all all trips view when choosing "packed" "moderate" "relaxed" the capsule the option is in does a weird animation that looks off.
|
||||
- Text on achievements is not wrapping and is being cutoff
|
||||
- Remove username on share
|
||||
- more on share doesn’t do anything
|
||||
- Created a poll and when I tap on it I get poll not found?
|
||||
- group poll refreshed every time I go to screen, should update in bg and pull to refresh?
|
||||
- User who made it should be able to delete it
|
||||
- Trip details aren’t showing
|
||||
- Home Screen quick start should be removed
|
||||
- Today games aren’t highlighted in the schedule tab
|
||||
- features trip showing both nba and nhl but only has a nhl game
|
||||
|
||||
|
||||
134
docs/BUG_FIX_PLAN.md
Normal file
134
docs/BUG_FIX_PLAN.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Bug Fix Plan
|
||||
|
||||
**Created:** 2026-01-14
|
||||
**Approach:** Batch by feature area, regression tests for each fix
|
||||
**Total Bugs:** 12
|
||||
|
||||
---
|
||||
|
||||
## Batch 1: Sharing & Achievements (4 bugs)
|
||||
|
||||
**Files to modify:**
|
||||
- `SportsTime/Export/Sharing/AchievementCardGenerator.swift`
|
||||
- `SportsTime/Features/Progress/Views/AchievementsListView.swift`
|
||||
- `SportsTime/Export/Sharing/ShareService.swift`
|
||||
|
||||
| Bug | Description | Root Cause Hypothesis | Fix Approach |
|
||||
|-----|-------------|----------------------|--------------|
|
||||
| **1.1** | Achievement text not wrapping, being cut off | Missing `.lineLimit(nil)` or fixed frame width | Add proper text wrapping constraints to `AchievementCard` |
|
||||
| **1.2** | Remove username on share | Username hardcoded in share card render | Remove username field from `AchievementCardGenerator` output |
|
||||
| **1.3** | "More" button on share doesn't do anything | Button has no action handler | Implement or remove the "More" button |
|
||||
| **1.4** | Achievements share says "2,026" instead of "2026" | Number formatting with thousands separator | Fix year formatting to not use locale grouping |
|
||||
|
||||
**Tests to add:**
|
||||
- `test_AchievementCard_TextWrapsCorrectly_LongAchievementNames`
|
||||
- `test_ShareCard_DoesNotIncludeUsername`
|
||||
- `test_YearFormatting_NoThousandsSeparator`
|
||||
|
||||
---
|
||||
|
||||
## Batch 2: Polls (4 bugs)
|
||||
|
||||
**Files to modify:**
|
||||
- `SportsTime/Features/Polls/ViewModels/PollDetailViewModel.swift`
|
||||
- `SportsTime/Features/Polls/Views/PollsListView.swift`
|
||||
- `SportsTime/Core/Services/PollService.swift`
|
||||
|
||||
| Bug | Description | Root Cause Hypothesis | Fix Approach |
|
||||
|-----|-------------|----------------------|--------------|
|
||||
| **2.1** | Poll not found when tapping created poll | Share code case mismatch or CloudKit sync delay | Verify case handling; add local cache lookup before CloudKit |
|
||||
| **2.2** | Poll refreshes every time screen appears | Missing cache, always fetching from CloudKit | Add local state caching, only fetch on pull-to-refresh |
|
||||
| **2.3** | Creator cannot delete their poll | Deletion permission check failing or UI missing | Verify `notPollOwner` check; add delete button for owner |
|
||||
| **2.4** | Trip details not showing in poll | Poll options not loading trip data | Check if trip reference is being dereferenced correctly |
|
||||
|
||||
**Tests to add:**
|
||||
- `test_Poll_FoundByShareCode_CaseInsensitive`
|
||||
- `test_PollsList_UsesLocalCache_NotRefreshOnAppear`
|
||||
- `test_PollOwner_CanDeleteOwnPoll`
|
||||
- `test_Poll_ShowsTripDetails_WhenLinked`
|
||||
|
||||
---
|
||||
|
||||
## Batch 3: Schedule & Games (3 bugs)
|
||||
|
||||
**Files to modify:**
|
||||
- `SportsTime/Features/Schedule/Views/ScheduleListView.swift`
|
||||
- `SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift`
|
||||
- `SportsTime/Features/Home/Views/Variants/Classic/HomeContent_Classic.swift`
|
||||
- Possibly: Data layer if timezone issue
|
||||
|
||||
| Bug | Description | Root Cause Hypothesis | Fix Approach |
|
||||
|-----|-------------|----------------------|--------------|
|
||||
| **3.1** | Games showing at 7 AM (incorrect time) | Timezone conversion issue (UTC displayed as local) | Investigate game time storage; fix timezone handling |
|
||||
| **3.2** | Today's games not highlighted | `isToday` cache not reactive or comparison bug | Check date comparison logic; ensure proper calendar usage |
|
||||
| **3.3** | Featured trip shows NBA+NHL but only has NHL game | Filter logic bug in `SuggestedTripsGenerator` | Fix sport filtering when generating featured trips |
|
||||
|
||||
**Tests to add:**
|
||||
- `test_GameTime_DisplaysInLocalTimezone`
|
||||
- `test_ScheduleList_HighlightsTodaysGames`
|
||||
- `test_FeaturedTrip_OnlyShowsSportsWithGames`
|
||||
|
||||
---
|
||||
|
||||
## Batch 4: Home Screen (1 bug)
|
||||
|
||||
**Files to modify:**
|
||||
- `SportsTime/Features/Home/Views/Variants/Classic/HomeContent_Classic.swift`
|
||||
- Possibly all 18+ design variants in `Features/Home/Views/Variants/`
|
||||
|
||||
| Bug | Description | Root Cause Hypothesis | Fix Approach |
|
||||
|-----|-------------|----------------------|--------------|
|
||||
| **4.1** | Remove Home Screen quick start | Feature no longer wanted | Remove `quickActions` section from all home variants |
|
||||
|
||||
**Tests to add:**
|
||||
- `test_HomeScreen_NoQuickStartSection`
|
||||
|
||||
**Note:** Need to check all 18+ design variants. May need to grep for `quickActions` or `SportSelectorGrid` across all variant files.
|
||||
|
||||
---
|
||||
|
||||
## Batch 5: Trips View (1 bug)
|
||||
|
||||
**Files to modify:**
|
||||
- `SportsTime/Features/Trip/Views/TripCreationView.swift` (or wherever pace selector lives)
|
||||
- Possibly: A filter view in trips list
|
||||
|
||||
| Bug | Description | Root Cause Hypothesis | Fix Approach |
|
||||
|-----|-------------|----------------------|--------------|
|
||||
| **5.1** | Pace capsule animation looks off when selecting packed/moderate/relaxed | Animation timing or layout issue with selection state | Investigate capsule animation; fix transition |
|
||||
|
||||
**Note from exploration:** The pace selector UI may be in a trips filtering view (line 1521 shows `TripPaceFilter`), not trip creation. Need to locate the exact view with the animation bug.
|
||||
|
||||
**Tests to add:**
|
||||
- UI test: `test_PaceSelector_AnimatesSmoothlOnSelection`
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. **Batch 1 (Sharing)** - Most visible to users, affects social features
|
||||
2. **Batch 2 (Polls)** - Existing functionality broke, needs regression fix
|
||||
3. **Batch 3 (Schedule)** - Core app functionality
|
||||
4. **Batch 4 (Home)** - Simple removal
|
||||
5. **Batch 5 (Trips)** - Minor UI polish
|
||||
|
||||
---
|
||||
|
||||
## Investigation Needed Before Fixes
|
||||
|
||||
| Item | Question | How to Investigate |
|
||||
|------|----------|-------------------|
|
||||
| 7 AM games | Is this timezone or bad data? | Check a specific game's stored `dateTime` vs expected local time |
|
||||
| Poll not found | Is it case sensitivity or sync timing? | Test with uppercase/lowercase share codes; check CloudKit logs |
|
||||
| More button | What should it do, or should it be removed? | Ask user for intended behavior |
|
||||
| Quick start | Remove from all 18 variants or just Classic? | Confirm scope with user |
|
||||
|
||||
---
|
||||
|
||||
## Decisions Made
|
||||
|
||||
| Question | Decision |
|
||||
|----------|----------|
|
||||
| "More" button on share | **Remove it** - button serves no purpose |
|
||||
| Quick start removal | **All 18+ variants** - for consistency |
|
||||
| Poll trip details | **Full summary** - cities, games, dates, duration |
|
||||
Reference in New Issue
Block a user