Stabilize beta release with warning cleanup and edge-case fixes
This commit is contained in:
@@ -19,6 +19,7 @@ final class PollDetailViewModel {
|
||||
var error: PollError?
|
||||
|
||||
private let pollService = PollService.shared
|
||||
private var subscribedPollId: UUID?
|
||||
|
||||
var results: PollResults? {
|
||||
guard let poll else { return nil }
|
||||
@@ -65,7 +66,7 @@ final class PollDetailViewModel {
|
||||
self.myVote = fetchedMyVote
|
||||
|
||||
// Subscribe to vote updates
|
||||
try? await pollService.subscribeToVoteUpdates(forPollId: pollId)
|
||||
await subscribeToVoteUpdates(for: pollId)
|
||||
} catch let pollError as PollError {
|
||||
error = pollError
|
||||
} catch {
|
||||
@@ -92,7 +93,7 @@ final class PollDetailViewModel {
|
||||
self.myVote = fetchedMyVote
|
||||
|
||||
// Subscribe to vote updates
|
||||
try? await pollService.subscribeToVoteUpdates(forPollId: fetchedPoll.id)
|
||||
await subscribeToVoteUpdates(for: fetchedPoll.id)
|
||||
} catch let pollError as PollError {
|
||||
error = pollError
|
||||
} catch {
|
||||
@@ -119,7 +120,7 @@ final class PollDetailViewModel {
|
||||
self.myVote = fetchedMyVote
|
||||
|
||||
// Subscribe to vote updates
|
||||
try? await pollService.subscribeToVoteUpdates(forPollId: existingPoll.id)
|
||||
await subscribeToVoteUpdates(for: existingPoll.id)
|
||||
} catch {
|
||||
// Votes fetch failed, but we have the poll - non-critical error
|
||||
self.votes = []
|
||||
@@ -155,7 +156,8 @@ final class PollDetailViewModel {
|
||||
|
||||
do {
|
||||
try await pollService.deletePoll(poll.id)
|
||||
try? await pollService.unsubscribeFromVoteUpdates()
|
||||
try? await pollService.unsubscribeFromVoteUpdates(forPollId: poll.id)
|
||||
subscribedPollId = nil
|
||||
self.poll = nil
|
||||
return true
|
||||
} catch let pollError as PollError {
|
||||
@@ -169,6 +171,17 @@ final class PollDetailViewModel {
|
||||
}
|
||||
|
||||
func cleanup() async {
|
||||
try? await pollService.unsubscribeFromVoteUpdates()
|
||||
guard let subscribedPollId else { return }
|
||||
try? await pollService.unsubscribeFromVoteUpdates(forPollId: subscribedPollId)
|
||||
self.subscribedPollId = nil
|
||||
}
|
||||
|
||||
private func subscribeToVoteUpdates(for pollId: UUID) async {
|
||||
if let existingPollId = subscribedPollId, existingPollId != pollId {
|
||||
try? await pollService.unsubscribeFromVoteUpdates(forPollId: existingPollId)
|
||||
}
|
||||
|
||||
try? await pollService.subscribeToVoteUpdates(forPollId: pollId)
|
||||
subscribedPollId = pollId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ struct PollsListView: View {
|
||||
@State private var polls: [TripPoll] = []
|
||||
@State private var isLoading = false
|
||||
@State private var hasLoadedInitially = false
|
||||
@State private var error: PollError?
|
||||
@State private var errorMessage: String?
|
||||
@State private var showJoinPoll = false
|
||||
@State private var joinCode = ""
|
||||
@State private var pendingJoinCode: IdentifiableShareCode?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -50,10 +51,17 @@ struct PollsListView: View {
|
||||
TextField("Enter code", text: $joinCode)
|
||||
.textInputAutocapitalization(.characters)
|
||||
Button("Join") {
|
||||
// Navigation will be handled by deep link
|
||||
if !joinCode.isEmpty {
|
||||
// TODO: Navigate to poll detail
|
||||
let normalizedCode = joinCode
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.uppercased()
|
||||
|
||||
guard normalizedCode.count == 6 else {
|
||||
errorMessage = "Share code must be exactly 6 characters."
|
||||
return
|
||||
}
|
||||
|
||||
pendingJoinCode = IdentifiableShareCode(id: normalizedCode)
|
||||
joinCode = ""
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
joinCode = ""
|
||||
@@ -61,14 +69,21 @@ struct PollsListView: View {
|
||||
} message: {
|
||||
Text("Enter the 6-character poll code")
|
||||
}
|
||||
.alert("Error", isPresented: .constant(error != nil)) {
|
||||
Button("OK") {
|
||||
error = nil
|
||||
.navigationDestination(item: $pendingJoinCode) { code in
|
||||
PollDetailView(shareCode: code.value)
|
||||
}
|
||||
.alert(
|
||||
"Error",
|
||||
isPresented: Binding(
|
||||
get: { errorMessage != nil },
|
||||
set: { if !$0 { errorMessage = nil } }
|
||||
)
|
||||
) {
|
||||
Button("OK", role: .cancel) {
|
||||
errorMessage = nil
|
||||
}
|
||||
} message: {
|
||||
if let error {
|
||||
Text(error.localizedDescription)
|
||||
}
|
||||
Text(errorMessage ?? "Please try again.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,14 +114,14 @@ struct PollsListView: View {
|
||||
|
||||
private func loadPolls() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
polls = try await PollService.shared.fetchMyPolls()
|
||||
} catch let pollError as PollError {
|
||||
error = pollError
|
||||
errorMessage = pollError.localizedDescription
|
||||
} catch {
|
||||
self.error = .unknown(error)
|
||||
self.errorMessage = PollError.unknown(error).localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
|
||||
@@ -54,12 +54,12 @@ final class ProgressViewModel {
|
||||
|
||||
/// Count of trips for the selected sport (stub - can be enhanced)
|
||||
var tripCount: Int {
|
||||
// TODO: Fetch saved trips count from SwiftData
|
||||
0
|
||||
savedTripCount
|
||||
}
|
||||
|
||||
/// Recent visits sorted by date (cached, recomputed on data change)
|
||||
private(set) var recentVisits: [VisitSummary] = []
|
||||
private(set) var savedTripCount: Int = 0
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
@@ -89,6 +89,7 @@ final class ProgressViewModel {
|
||||
sortBy: [SortDescriptor(\.visitDate, order: .reverse)]
|
||||
)
|
||||
visits = try context.fetch(descriptor)
|
||||
savedTripCount = try context.fetchCount(FetchDescriptor<SavedTrip>())
|
||||
}
|
||||
} catch {
|
||||
self.error = error
|
||||
|
||||
@@ -16,8 +16,8 @@ final class ScheduleViewModel {
|
||||
// MARK: - Filter State
|
||||
|
||||
var selectedSports: Set<Sport> = Set(Sport.supported)
|
||||
var startDate: Date = Calendar.current.startOfDay(for: Date())
|
||||
var endDate: Date = Calendar.current.date(byAdding: .day, value: 14, to: Calendar.current.startOfDay(for: Date())) ?? Date()
|
||||
var startDate: Date = ScheduleViewModel.defaultDateRange().start
|
||||
var endDate: Date = ScheduleViewModel.defaultDateRange().end
|
||||
var searchText: String = ""
|
||||
|
||||
// MARK: - Data State
|
||||
@@ -28,7 +28,9 @@ final class ScheduleViewModel {
|
||||
private(set) var errorMessage: String?
|
||||
|
||||
private let dataProvider = AppDataProvider.shared
|
||||
@ObservationIgnored
|
||||
nonisolated(unsafe) private var loadTask: Task<Void, Never>?
|
||||
private var latestLoadRequestID = UUID()
|
||||
|
||||
// MARK: - Pre-computed Groupings (avoid computed property overhead)
|
||||
|
||||
@@ -48,14 +50,43 @@ final class ScheduleViewModel {
|
||||
}
|
||||
|
||||
var hasFilters: Bool {
|
||||
selectedSports.count < Sport.supported.count || !searchText.isEmpty
|
||||
let defaults = Self.defaultDateRange()
|
||||
let calendar = Calendar.current
|
||||
let hasDateFilter = !calendar.isDate(startDate, inSameDayAs: defaults.start) ||
|
||||
!calendar.isDate(endDate, inSameDayAs: defaults.end)
|
||||
return selectedSports.count < Sport.supported.count || !searchText.isEmpty || hasDateFilter
|
||||
}
|
||||
|
||||
private static func defaultDateRange() -> (start: Date, end: Date) {
|
||||
let calendar = Calendar.current
|
||||
let start = calendar.startOfDay(for: Date())
|
||||
let endDay = calendar.date(byAdding: .day, value: 14, to: start) ?? start
|
||||
let end = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay
|
||||
return (start, end)
|
||||
}
|
||||
|
||||
private static func normalizedDateRange(start: Date, end: Date) -> (start: Date, end: Date) {
|
||||
let calendar = Calendar.current
|
||||
let normalizedStart = calendar.startOfDay(for: start)
|
||||
let endDay = calendar.startOfDay(for: end)
|
||||
let normalizedEnd = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay
|
||||
return (normalizedStart, normalizedEnd)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func loadGames() async {
|
||||
guard !selectedSports.isEmpty else {
|
||||
let requestID = beginLoadRequest()
|
||||
let queryStartDate = startDate
|
||||
let queryEndDate = endDate
|
||||
let querySports = selectedSports
|
||||
|
||||
guard !querySports.isEmpty else {
|
||||
guard !isStaleLoad(requestID) else { return }
|
||||
games = []
|
||||
isLoading = false
|
||||
error = nil
|
||||
errorMessage = nil
|
||||
updateFilteredGames()
|
||||
return
|
||||
}
|
||||
@@ -64,11 +95,6 @@ final class ScheduleViewModel {
|
||||
error = nil
|
||||
errorMessage = nil
|
||||
|
||||
// Start diagnostics
|
||||
let queryStartDate = startDate
|
||||
let queryEndDate = endDate
|
||||
let querySports = selectedSports
|
||||
|
||||
logger.info("📅 Loading games: \(querySports.map(\.rawValue).joined(separator: ", "))")
|
||||
logger.info("📅 Date range: \(queryStartDate.formatted()) to \(queryEndDate.formatted())")
|
||||
|
||||
@@ -78,9 +104,11 @@ final class ScheduleViewModel {
|
||||
logger.info("📅 Teams empty, loading initial data...")
|
||||
await dataProvider.loadInitialData()
|
||||
}
|
||||
guard !isStaleLoad(requestID) else { return }
|
||||
|
||||
// Check if data provider had an error
|
||||
if let providerError = dataProvider.errorMessage {
|
||||
guard !isStaleLoad(requestID) else { return }
|
||||
self.errorMessage = providerError
|
||||
self.error = dataProvider.error
|
||||
isLoading = false
|
||||
@@ -92,11 +120,13 @@ final class ScheduleViewModel {
|
||||
// Log team/stadium counts for diagnostics
|
||||
logger.info("📅 Loaded \(self.dataProvider.teams.count) teams, \(self.dataProvider.stadiums.count) stadiums")
|
||||
|
||||
games = try await dataProvider.filterRichGames(
|
||||
sports: selectedSports,
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
let loadedGames = try await dataProvider.filterRichGames(
|
||||
sports: querySports,
|
||||
startDate: queryStartDate,
|
||||
endDate: queryEndDate
|
||||
)
|
||||
guard !isStaleLoad(requestID) else { return }
|
||||
games = loadedGames
|
||||
|
||||
// Update diagnostics
|
||||
var newDiagnostics = ScheduleDiagnostics()
|
||||
@@ -116,7 +146,7 @@ final class ScheduleViewModel {
|
||||
|
||||
self.diagnostics = newDiagnostics
|
||||
|
||||
AnalyticsManager.shared.track(.scheduleViewed(sports: Array(selectedSports).map(\.rawValue)))
|
||||
AnalyticsManager.shared.track(.scheduleViewed(sports: Array(querySports).map(\.rawValue)))
|
||||
logger.info("📅 Returned \(self.games.count) games")
|
||||
for (sport, count) in sportCounts.sorted(by: { $0.key.rawValue < $1.key.rawValue }) {
|
||||
logger.info("📅 \(sport.rawValue): \(count) games")
|
||||
@@ -133,15 +163,18 @@ final class ScheduleViewModel {
|
||||
#endif
|
||||
|
||||
} catch let cloudKitError as CloudKitError {
|
||||
guard !isStaleLoad(requestID) else { return }
|
||||
self.error = cloudKitError
|
||||
self.errorMessage = cloudKitError.errorDescription
|
||||
logger.error("📅 CloudKit error: \(cloudKitError.errorDescription ?? "unknown")")
|
||||
} catch {
|
||||
guard !isStaleLoad(requestID) else { return }
|
||||
self.error = error
|
||||
self.errorMessage = error.localizedDescription
|
||||
logger.error("📅 Error loading games: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
guard !isStaleLoad(requestID) else { return }
|
||||
isLoading = false
|
||||
updateFilteredGames()
|
||||
}
|
||||
@@ -165,15 +198,17 @@ final class ScheduleViewModel {
|
||||
func resetFilters() {
|
||||
selectedSports = Set(Sport.supported)
|
||||
searchText = ""
|
||||
startDate = Date()
|
||||
endDate = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date()
|
||||
let defaults = Self.defaultDateRange()
|
||||
startDate = defaults.start
|
||||
endDate = defaults.end
|
||||
loadTask?.cancel()
|
||||
loadTask = Task { await loadGames() }
|
||||
}
|
||||
|
||||
func updateDateRange(start: Date, end: Date) {
|
||||
startDate = start
|
||||
endDate = end
|
||||
let normalized = Self.normalizedDateRange(start: start, end: end)
|
||||
startDate = normalized.start
|
||||
endDate = normalized.end
|
||||
loadTask?.cancel()
|
||||
loadTask = Task { await loadGames() }
|
||||
}
|
||||
@@ -212,6 +247,16 @@ final class ScheduleViewModel {
|
||||
return lhs.game.dateTime < rhs.game.dateTime
|
||||
}) }
|
||||
}
|
||||
|
||||
private func beginLoadRequest() -> UUID {
|
||||
let requestID = UUID()
|
||||
latestLoadRequestID = requestID
|
||||
return requestID
|
||||
}
|
||||
|
||||
private func isStaleLoad(_ requestID: UUID) -> Bool {
|
||||
Task.isCancelled || latestLoadRequestID != requestID
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Diagnostics Model
|
||||
|
||||
@@ -362,18 +362,15 @@ struct DateRangePickerSheet: View {
|
||||
|
||||
Section {
|
||||
Button("Next 7 Days") {
|
||||
startDate = Date()
|
||||
endDate = Calendar.current.date(byAdding: .day, value: 7, to: Date()) ?? Date()
|
||||
applyQuickRange(days: 7)
|
||||
}
|
||||
|
||||
Button("Next 14 Days") {
|
||||
startDate = Date()
|
||||
endDate = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date()
|
||||
applyQuickRange(days: 14)
|
||||
}
|
||||
|
||||
Button("Next 30 Days") {
|
||||
startDate = Date()
|
||||
endDate = Calendar.current.date(byAdding: .day, value: 30, to: Date()) ?? Date()
|
||||
applyQuickRange(days: 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,6 +391,15 @@ struct DateRangePickerSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyQuickRange(days: Int) {
|
||||
let calendar = Calendar.current
|
||||
let start = calendar.startOfDay(for: Date())
|
||||
let endDay = calendar.date(byAdding: .day, value: days, to: start) ?? start
|
||||
let end = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay
|
||||
startDate = start
|
||||
endDate = end
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Schedule Diagnostics Sheet
|
||||
|
||||
@@ -216,10 +216,10 @@ struct SportsIconImageGeneratorView: View {
|
||||
.disabled(isGenerating)
|
||||
|
||||
// Share button (only visible when image exists)
|
||||
if generatedImage != nil {
|
||||
if let generatedImage {
|
||||
ShareLink(
|
||||
item: Image(uiImage: generatedImage!),
|
||||
preview: SharePreview("Sports Icons", image: Image(uiImage: generatedImage!))
|
||||
item: Image(uiImage: generatedImage),
|
||||
preview: SharePreview("Sports Icons", image: Image(uiImage: generatedImage))
|
||||
) {
|
||||
HStack {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
|
||||
@@ -494,7 +494,7 @@ struct SettingsView: View {
|
||||
Button {
|
||||
Task {
|
||||
let syncService = CanonicalSyncService()
|
||||
await syncService.resumeSync(context: modelContext)
|
||||
syncService.resumeSync(context: modelContext)
|
||||
syncActionMessage = "Sync has been re-enabled."
|
||||
}
|
||||
} label: {
|
||||
@@ -670,7 +670,7 @@ struct SettingsView: View {
|
||||
Button {
|
||||
Task {
|
||||
let syncService = CanonicalSyncService()
|
||||
await syncService.resumeSync(context: modelContext)
|
||||
syncService.resumeSync(context: modelContext)
|
||||
print("[SyncDebug] Sync re-enabled by user")
|
||||
}
|
||||
} label: {
|
||||
|
||||
@@ -27,8 +27,13 @@ final class TripWizardViewModel {
|
||||
|
||||
// MARK: - Dates
|
||||
|
||||
var startDate: Date = Date()
|
||||
var endDate: Date = Date().addingTimeInterval(86400 * 7)
|
||||
var startDate: Date = Calendar.current.startOfDay(for: Date())
|
||||
var endDate: Date = {
|
||||
let calendar = Calendar.current
|
||||
let start = calendar.startOfDay(for: Date())
|
||||
let endDay = calendar.date(byAdding: .day, value: 7, to: start) ?? start
|
||||
return calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay
|
||||
}()
|
||||
var hasSetDates: Bool = false
|
||||
|
||||
// MARK: - Regions
|
||||
@@ -212,13 +217,17 @@ final class TripWizardViewModel {
|
||||
defer { isLoadingSportAvailability = false }
|
||||
|
||||
var availability: [Sport: Bool] = [:]
|
||||
let calendar = Calendar.current
|
||||
let queryStart = calendar.startOfDay(for: startDate)
|
||||
let endDay = calendar.startOfDay(for: endDate)
|
||||
let queryEnd = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay
|
||||
|
||||
for sport in Sport.supported {
|
||||
do {
|
||||
let games = try await AppDataProvider.shared.filterGames(
|
||||
sports: [sport],
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
startDate: queryStart,
|
||||
endDate: queryEnd
|
||||
)
|
||||
availability[sport] = !games.isEmpty
|
||||
} catch {
|
||||
|
||||
@@ -449,20 +449,13 @@ private struct PlaceRow: View {
|
||||
}
|
||||
|
||||
private var formattedAddress: String? {
|
||||
let placemark = place.placemark
|
||||
var components: [String] = []
|
||||
|
||||
if let thoroughfare = placemark.thoroughfare {
|
||||
components.append(thoroughfare)
|
||||
if let shortAddress = place.address?.shortAddress, !shortAddress.isEmpty {
|
||||
return shortAddress
|
||||
}
|
||||
if let locality = placemark.locality {
|
||||
components.append(locality)
|
||||
if let fullAddress = place.address?.fullAddress, !fullAddress.isEmpty {
|
||||
return fullAddress
|
||||
}
|
||||
if let administrativeArea = placemark.administrativeArea {
|
||||
components.append(administrativeArea)
|
||||
}
|
||||
|
||||
return components.isEmpty ? nil : components.joined(separator: ", ")
|
||||
return place.addressRepresentations?.cityWithContext
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -429,10 +429,14 @@ struct QuickAddItemSheet: View {
|
||||
// Restore location if present
|
||||
if let lat = info.latitude,
|
||||
let lon = info.longitude {
|
||||
let placemark = MKPlacemark(
|
||||
coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lon)
|
||||
)
|
||||
let mapItem = MKMapItem(placemark: placemark)
|
||||
let location = CLLocation(latitude: lat, longitude: lon)
|
||||
let mapItem: MKMapItem
|
||||
if #available(iOS 26.0, *) {
|
||||
mapItem = MKMapItem(location: location, address: nil)
|
||||
} else {
|
||||
let placemark = MKPlacemark(coordinate: location.coordinate)
|
||||
mapItem = MKMapItem(placemark: placemark)
|
||||
}
|
||||
mapItem.name = info.title
|
||||
selectedPlace = mapItem
|
||||
}
|
||||
@@ -447,7 +451,7 @@ struct QuickAddItemSheet: View {
|
||||
|
||||
if let place = selectedPlace {
|
||||
// Item with location
|
||||
let coordinate = place.placemark.coordinate
|
||||
let coordinate = mapItemCoordinate(for: place)
|
||||
customInfo = CustomInfo(
|
||||
title: trimmedTitle,
|
||||
icon: "\u{1F4CC}",
|
||||
@@ -489,21 +493,18 @@ struct QuickAddItemSheet: View {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func mapItemCoordinate(for place: MKMapItem) -> CLLocationCoordinate2D {
|
||||
place.location.coordinate
|
||||
}
|
||||
|
||||
private func formatAddress(for place: MKMapItem) -> String? {
|
||||
let placemark = place.placemark
|
||||
var components: [String] = []
|
||||
|
||||
if let thoroughfare = placemark.thoroughfare {
|
||||
components.append(thoroughfare)
|
||||
if let shortAddress = place.address?.shortAddress, !shortAddress.isEmpty {
|
||||
return shortAddress
|
||||
}
|
||||
if let locality = placemark.locality {
|
||||
components.append(locality)
|
||||
if let fullAddress = place.address?.fullAddress, !fullAddress.isEmpty {
|
||||
return fullAddress
|
||||
}
|
||||
if let administrativeArea = placemark.administrativeArea {
|
||||
components.append(administrativeArea)
|
||||
}
|
||||
|
||||
return components.isEmpty ? nil : components.joined(separator: ", ")
|
||||
return place.addressRepresentations?.cityWithContext
|
||||
}
|
||||
|
||||
// MARK: - POI Loading
|
||||
|
||||
@@ -340,24 +340,9 @@ private struct PlaceResultRow: View {
|
||||
}
|
||||
|
||||
private var formattedAddress: String? {
|
||||
let placemark = item.placemark
|
||||
var components: [String] = []
|
||||
|
||||
if let locality = placemark.locality {
|
||||
components.append(locality)
|
||||
if let cityContext = item.addressRepresentations?.cityWithContext, !cityContext.isEmpty {
|
||||
return cityContext
|
||||
}
|
||||
if let administrativeArea = placemark.administrativeArea {
|
||||
components.append(administrativeArea)
|
||||
}
|
||||
|
||||
return components.isEmpty ? nil : components.joined(separator: ", ")
|
||||
return item.address?.shortAddress ?? item.address?.fullAddress
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AddItemSheet(
|
||||
tripId: UUID(),
|
||||
day: 1,
|
||||
existingItem: nil
|
||||
) { _ in }
|
||||
}
|
||||
|
||||
@@ -715,10 +715,7 @@ enum ItineraryReorderingLogic {
|
||||
travelValidRanges: [:], // Custom items don't use travel ranges
|
||||
constraints: constraints,
|
||||
findTravelItem: { _ in nil },
|
||||
makeTravelItem: { _ in
|
||||
// This won't be called for custom items
|
||||
fatalError("makeTravelItem called for custom item")
|
||||
},
|
||||
makeTravelItem: { _ in item },
|
||||
findTravelSortOrder: findTravelSortOrder
|
||||
)
|
||||
|
||||
|
||||
@@ -320,7 +320,7 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
||||
.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||
}
|
||||
|
||||
private func stableTravelAnchorId(_ segment: TravelSegment, at index: Int) -> String {
|
||||
nonisolated private func stableTravelAnchorId(_ segment: TravelSegment, at index: Int) -> String {
|
||||
let from = TravelInfo.normalizeCityName(segment.fromLocation.name)
|
||||
let to = TravelInfo.normalizeCityName(segment.toLocation.name)
|
||||
return "travel:\(index):\(from)->\(to)"
|
||||
|
||||
@@ -21,6 +21,7 @@ struct TripDetailView: View {
|
||||
|
||||
@Query private var savedTrips: [SavedTrip]
|
||||
@State private var showProPaywall = false
|
||||
@State private var persistenceErrorMessage: String?
|
||||
@State private var selectedDay: ItineraryDay?
|
||||
@State private var showExportSheet = false
|
||||
@State private var exportURL: URL?
|
||||
@@ -119,6 +120,17 @@ struct TripDetailView: View {
|
||||
} message: {
|
||||
Text("This trip has \(multiRouteChunks.flatMap { $0 }.count) stops, which exceeds Apple Maps' limit of 16. Open the route in parts?")
|
||||
}
|
||||
.alert(
|
||||
"Unable to Save Changes",
|
||||
isPresented: Binding(
|
||||
get: { persistenceErrorMessage != nil },
|
||||
set: { if !$0 { persistenceErrorMessage = nil } }
|
||||
)
|
||||
) {
|
||||
Button("OK", role: .cancel) { persistenceErrorMessage = nil }
|
||||
} message: {
|
||||
Text(persistenceErrorMessage ?? "Please try again.")
|
||||
}
|
||||
.onAppear {
|
||||
AnalyticsManager.shared.track(.tripViewed(tripId: trip.id.uuidString, source: allowCustomItems ? "saved" : "new"))
|
||||
checkIfSaved()
|
||||
@@ -1354,7 +1366,7 @@ struct TripDetailView: View {
|
||||
gameCount: trip.totalGames
|
||||
))
|
||||
} catch {
|
||||
// Save failed silently
|
||||
persistenceErrorMessage = "Failed to save this trip. Please try again."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1375,7 +1387,7 @@ struct TripDetailView: View {
|
||||
}
|
||||
AnalyticsManager.shared.track(.tripDeleted(tripId: tripId.uuidString))
|
||||
} catch {
|
||||
// Unsave failed silently
|
||||
persistenceErrorMessage = "Failed to remove this saved trip. Please try again."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1404,7 +1416,13 @@ struct TripDetailView: View {
|
||||
let descriptor = FetchDescriptor<LocalItineraryItem>(
|
||||
predicate: #Predicate { $0.tripId == tripId }
|
||||
)
|
||||
let localItems = (try? modelContext.fetch(descriptor))?.compactMap(\.toItem) ?? []
|
||||
let localModels = (try? modelContext.fetch(descriptor)) ?? []
|
||||
let localItems = localModels.compactMap(\.toItem)
|
||||
let pendingLocalItems = localModels.compactMap { local -> ItineraryItem? in
|
||||
guard local.pendingSync else { return nil }
|
||||
return local.toItem
|
||||
}
|
||||
|
||||
if !localItems.isEmpty {
|
||||
#if DEBUG
|
||||
print("✅ [ItineraryItems] Loaded \(localItems.count) items from local cache")
|
||||
@@ -1420,16 +1438,42 @@ struct TripDetailView: View {
|
||||
print("✅ [ItineraryItems] Loaded \(cloudItems.count) items from CloudKit")
|
||||
#endif
|
||||
|
||||
// Merge: use CloudKit as source of truth when available
|
||||
if !cloudItems.isEmpty || localItems.isEmpty {
|
||||
itineraryItems = cloudItems
|
||||
extractTravelOverrides(from: cloudItems)
|
||||
syncLocalCache(with: cloudItems)
|
||||
// Merge: cloud as baseline, but always preserve local pending edits.
|
||||
let cloudById = Dictionary(uniqueKeysWithValues: cloudItems.map { ($0.id, $0) })
|
||||
let unresolvedPendingItems = pendingLocalItems.filter { localPending in
|
||||
guard let cloudItem = cloudById[localPending.id] else { return true }
|
||||
return cloudItem.modifiedAt < localPending.modifiedAt
|
||||
}
|
||||
|
||||
var mergedById = cloudById
|
||||
for localPending in unresolvedPendingItems {
|
||||
mergedById[localPending.id] = localPending
|
||||
}
|
||||
|
||||
let mergedItems = mergedById.values.sorted { lhs, rhs in
|
||||
if lhs.day != rhs.day { return lhs.day < rhs.day }
|
||||
if lhs.sortOrder != rhs.sortOrder { return lhs.sortOrder < rhs.sortOrder }
|
||||
return lhs.modifiedAt < rhs.modifiedAt
|
||||
}
|
||||
|
||||
if !mergedItems.isEmpty || localItems.isEmpty {
|
||||
itineraryItems = mergedItems
|
||||
extractTravelOverrides(from: mergedItems)
|
||||
syncLocalCache(with: mergedItems, pendingSyncItemIDs: Set(unresolvedPendingItems.map(\.id)))
|
||||
}
|
||||
|
||||
// Ensure unsynced local edits continue retrying in the background.
|
||||
for pendingItem in unresolvedPendingItems {
|
||||
await ItineraryItemService.shared.updateItem(pendingItem)
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("⚠️ [ItineraryItems] CloudKit fetch failed (using local cache): \(error)")
|
||||
#endif
|
||||
|
||||
for pendingItem in pendingLocalItems {
|
||||
await ItineraryItemService.shared.updateItem(pendingItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1475,7 +1519,7 @@ struct TripDetailView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
private func syncLocalCache(with items: [ItineraryItem]) {
|
||||
private func syncLocalCache(with items: [ItineraryItem], pendingSyncItemIDs: Set<UUID> = []) {
|
||||
let tripId = trip.id
|
||||
let descriptor = FetchDescriptor<LocalItineraryItem>(
|
||||
predicate: #Predicate { $0.tripId == tripId }
|
||||
@@ -1483,7 +1527,7 @@ struct TripDetailView: View {
|
||||
let existing = (try? modelContext.fetch(descriptor)) ?? []
|
||||
for old in existing { modelContext.delete(old) }
|
||||
for item in items {
|
||||
if let local = LocalItineraryItem.from(item) {
|
||||
if let local = LocalItineraryItem.from(item, pendingSync: pendingSyncItemIDs.contains(item.id)) {
|
||||
modelContext.insert(local)
|
||||
}
|
||||
}
|
||||
@@ -1524,6 +1568,7 @@ struct TripDetailView: View {
|
||||
markLocalItemSynced(item.id)
|
||||
}
|
||||
} catch {
|
||||
await ItineraryItemService.shared.updateItem(item)
|
||||
#if DEBUG
|
||||
print("⚠️ [ItineraryItems] CloudKit save failed (saved locally): \(error)")
|
||||
#endif
|
||||
@@ -1542,6 +1587,8 @@ struct TripDetailView: View {
|
||||
existing.kindData = (try? JSONEncoder().encode(item.kind)) ?? existing.kindData
|
||||
existing.modifiedAt = item.modifiedAt
|
||||
existing.pendingSync = true
|
||||
} else if let local = LocalItineraryItem.from(item, pendingSync: true) {
|
||||
modelContext.insert(local)
|
||||
}
|
||||
} else {
|
||||
if let local = LocalItineraryItem.from(item, pendingSync: true) {
|
||||
@@ -1598,8 +1645,6 @@ struct TripDetailView: View {
|
||||
|
||||
// Capture and clear drag state immediately (synchronously) before async work
|
||||
// This ensures the UI resets even if validation fails
|
||||
let capturedTravelId = draggedTravelId
|
||||
let capturedItem = draggedItem
|
||||
draggedTravelId = nil
|
||||
draggedItem = nil
|
||||
dropTargetId = nil
|
||||
|
||||
@@ -338,8 +338,7 @@ struct GamePickerStep: View {
|
||||
endDate = calendar.startOfDay(for: newEnd)
|
||||
} else {
|
||||
// Multiple games: span from first to last with 1-day buffer
|
||||
let firstGameDate = gameDates.first!
|
||||
let lastGameDate = gameDates.last!
|
||||
guard let firstGameDate = gameDates.first, let lastGameDate = gameDates.last else { return }
|
||||
let newStart = calendar.date(byAdding: .day, value: -1, to: firstGameDate) ?? firstGameDate
|
||||
let newEnd = calendar.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate
|
||||
startDate = calendar.startOfDay(for: newStart)
|
||||
|
||||
@@ -190,7 +190,7 @@ struct TripWizardView: View {
|
||||
defer { viewModel.isPlanning = false }
|
||||
|
||||
do {
|
||||
var preferences = buildPreferences()
|
||||
let preferences = buildPreferences()
|
||||
|
||||
// Build dictionaries from arrays
|
||||
let teamsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.teams.map { ($0.id, $0) })
|
||||
@@ -231,10 +231,15 @@ struct TripWizardView: View {
|
||||
}
|
||||
} else {
|
||||
// Standard mode: fetch games for date range
|
||||
let calendar = Calendar.current
|
||||
let queryStart = calendar.startOfDay(for: preferences.startDate)
|
||||
let endDay = calendar.startOfDay(for: preferences.endDate)
|
||||
let queryEnd = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay
|
||||
|
||||
games = try await AppDataProvider.shared.filterGames(
|
||||
sports: preferences.sports,
|
||||
startDate: preferences.startDate,
|
||||
endDate: preferences.endDate
|
||||
startDate: queryStart,
|
||||
endDate: queryEnd
|
||||
)
|
||||
}
|
||||
|
||||
@@ -292,6 +297,10 @@ struct TripWizardView: View {
|
||||
}
|
||||
|
||||
private func buildPreferences() -> TripPreferences {
|
||||
let calendar = Calendar.current
|
||||
let normalizedStartDate = calendar.startOfDay(for: viewModel.startDate)
|
||||
let normalizedEndDate = calendar.startOfDay(for: viewModel.endDate)
|
||||
|
||||
// Determine which sports to use based on mode
|
||||
let sports: Set<Sport>
|
||||
if viewModel.planningMode == .gameFirst {
|
||||
@@ -310,8 +319,8 @@ struct TripWizardView: View {
|
||||
endLocation: viewModel.endLocation,
|
||||
sports: sports,
|
||||
mustSeeGameIds: viewModel.selectedGameIds,
|
||||
startDate: viewModel.startDate,
|
||||
endDate: viewModel.endDate,
|
||||
startDate: normalizedStartDate,
|
||||
endDate: normalizedEndDate,
|
||||
mustStopLocations: viewModel.mustStopLocations,
|
||||
routePreference: viewModel.routePreference,
|
||||
allowRepeatCities: viewModel.allowRepeatCities,
|
||||
|
||||
Reference in New Issue
Block a user