Stabilize beta release with warning cleanup and edge-case fixes
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user