feat(itinerary): add custom itinerary items with drag-to-reorder
- Add CustomItineraryItem domain model with sortOrder for ordering - Add CKCustomItineraryItem CloudKit wrapper for persistence - Create CustomItemService for CRUD operations - Create CustomItemSubscriptionService for real-time sync - Add AppDelegate for push notification handling - Add AddItemSheet for creating/editing items - Add CustomItemRow with drag handle - Update TripDetailView with continuous vertical timeline - Enable drag-to-reorder using .draggable/.dropDestination - Add inline "Add" buttons after games and travel segments Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -315,7 +315,7 @@ struct SavedTripCard: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationLink {
|
||||
TripDetailView(trip: trip)
|
||||
TripDetailView(trip: trip, games: savedTrip.games, allowCustomItems: true)
|
||||
} label: {
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
// Route preview icon
|
||||
@@ -555,7 +555,7 @@ struct SavedTripsListView: View {
|
||||
ForEach(Array(sortedTrips.enumerated()), id: \.element.id) { index, savedTrip in
|
||||
if let trip = savedTrip.trip {
|
||||
NavigationLink {
|
||||
TripDetailView(trip: trip)
|
||||
TripDetailView(trip: trip, games: savedTrip.games, allowCustomItems: true)
|
||||
} label: {
|
||||
SavedTripListRow(trip: trip)
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ struct PollDetailView: View {
|
||||
}
|
||||
.sheet(item: $selectedTrip) { trip in
|
||||
NavigationStack {
|
||||
TripDetailView(trip: trip)
|
||||
TripDetailView(trip: trip, allowCustomItems: true)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
|
||||
149
SportsTime/Features/Trip/Views/AddItemSheet.swift
Normal file
149
SportsTime/Features/Trip/Views/AddItemSheet.swift
Normal file
@@ -0,0 +1,149 @@
|
||||
//
|
||||
// AddItemSheet.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Sheet for adding/editing custom itinerary items
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AddItemSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let tripId: UUID
|
||||
let anchorDay: Int
|
||||
let anchorType: CustomItineraryItem.AnchorType
|
||||
let anchorId: String?
|
||||
let existingItem: CustomItineraryItem?
|
||||
var onSave: (CustomItineraryItem) -> Void
|
||||
|
||||
@State private var selectedCategory: CustomItineraryItem.ItemCategory = .restaurant
|
||||
@State private var title: String = ""
|
||||
@State private var isSaving = false
|
||||
|
||||
private var isEditing: Bool { existingItem != nil }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// Category picker
|
||||
categoryPicker
|
||||
|
||||
// Title input
|
||||
TextField("What's the plan?", text: $title)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.body)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.background(Theme.backgroundGradient(colorScheme))
|
||||
.navigationTitle(isEditing ? "Edit Item" : "Add Item")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(isEditing ? "Save" : "Add") {
|
||||
saveItem()
|
||||
}
|
||||
.disabled(title.trimmingCharacters(in: .whitespaces).isEmpty || isSaving)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let existing = existingItem {
|
||||
selectedCategory = existing.category
|
||||
title = existing.title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var categoryPicker: some View {
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
ForEach(CustomItineraryItem.ItemCategory.allCases, id: \.self) { category in
|
||||
CategoryButton(
|
||||
category: category,
|
||||
isSelected: selectedCategory == category
|
||||
) {
|
||||
selectedCategory = category
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveItem() {
|
||||
let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedTitle.isEmpty else { return }
|
||||
|
||||
isSaving = true
|
||||
|
||||
let item: CustomItineraryItem
|
||||
if let existing = existingItem {
|
||||
item = CustomItineraryItem(
|
||||
id: existing.id,
|
||||
tripId: existing.tripId,
|
||||
category: selectedCategory,
|
||||
title: trimmedTitle,
|
||||
anchorType: existing.anchorType,
|
||||
anchorId: existing.anchorId,
|
||||
anchorDay: existing.anchorDay,
|
||||
createdAt: existing.createdAt,
|
||||
modifiedAt: Date()
|
||||
)
|
||||
} else {
|
||||
item = CustomItineraryItem(
|
||||
tripId: tripId,
|
||||
category: selectedCategory,
|
||||
title: trimmedTitle,
|
||||
anchorType: anchorType,
|
||||
anchorId: anchorId,
|
||||
anchorDay: anchorDay
|
||||
)
|
||||
}
|
||||
|
||||
onSave(item)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Button
|
||||
|
||||
private struct CategoryButton: View {
|
||||
let category: CustomItineraryItem.ItemCategory
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 4) {
|
||||
Text(category.icon)
|
||||
.font(.title2)
|
||||
Text(category.label)
|
||||
.font(.caption2)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(isSelected ? Theme.warmOrange.opacity(0.2) : Color.clear)
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(isSelected ? Theme.warmOrange : Color.secondary.opacity(0.3), lineWidth: isSelected ? 2 : 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AddItemSheet(
|
||||
tripId: UUID(),
|
||||
anchorDay: 1,
|
||||
anchorType: .startOfDay,
|
||||
anchorId: nil,
|
||||
existingItem: nil
|
||||
) { _ in }
|
||||
}
|
||||
89
SportsTime/Features/Trip/Views/CustomItemRow.swift
Normal file
89
SportsTime/Features/Trip/Views/CustomItemRow.swift
Normal file
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// CustomItemRow.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Row component for custom itinerary items with drag handle
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CustomItemRow: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let item: CustomItineraryItem
|
||||
var onTap: () -> Void
|
||||
var onDelete: () -> Void
|
||||
|
||||
// Drag handle visible - users can drag to reorder using .draggable/.dropDestination
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 12) {
|
||||
// Drag handle
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.foregroundStyle(.tertiary)
|
||||
.font(.caption)
|
||||
|
||||
// Category icon
|
||||
Text(item.category.icon)
|
||||
.font(.title3)
|
||||
|
||||
// Title
|
||||
Text(item.title)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
.lineLimit(2)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Chevron to indicate tappable (tap to edit)
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.tertiary)
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
.padding(.vertical, Theme.Spacing.sm)
|
||||
.background(Theme.warmOrange.opacity(0.08))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
Button {
|
||||
onTap()
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
onDelete()
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack {
|
||||
CustomItemRow(
|
||||
item: CustomItineraryItem(
|
||||
tripId: UUID(),
|
||||
category: .restaurant,
|
||||
title: "Joe's BBQ - Best brisket in Texas!",
|
||||
anchorDay: 1
|
||||
),
|
||||
onTap: {},
|
||||
onDelete: {}
|
||||
)
|
||||
CustomItemRow(
|
||||
item: CustomItineraryItem(
|
||||
tripId: UUID(),
|
||||
category: .hotel,
|
||||
title: "Hilton Downtown",
|
||||
anchorDay: 1
|
||||
),
|
||||
onTap: {},
|
||||
onDelete: {}
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import MapKit
|
||||
import Combine
|
||||
|
||||
struct TripDetailView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@@ -14,6 +15,9 @@ struct TripDetailView: View {
|
||||
let trip: Trip
|
||||
private let providedGames: [String: RichGame]?
|
||||
|
||||
/// When true, shows Add buttons and custom items (only for saved trips)
|
||||
private let allowCustomItems: Bool
|
||||
|
||||
@Query private var savedTrips: [SavedTrip]
|
||||
@State private var showProPaywall = false
|
||||
@State private var selectedDay: ItineraryDay?
|
||||
@@ -28,6 +32,13 @@ struct TripDetailView: View {
|
||||
@State private var loadedGames: [String: RichGame] = [:]
|
||||
@State private var isLoadingGames = false
|
||||
|
||||
// Custom items state
|
||||
@State private var customItems: [CustomItineraryItem] = []
|
||||
@State private var addItemAnchor: AddItemAnchor?
|
||||
@State private var editingItem: CustomItineraryItem?
|
||||
@State private var subscriptionCancellable: AnyCancellable?
|
||||
@State private var draggedItem: CustomItineraryItem?
|
||||
|
||||
private let exportService = ExportService()
|
||||
private let dataProvider = AppDataProvider.shared
|
||||
|
||||
@@ -36,16 +47,32 @@ struct TripDetailView: View {
|
||||
providedGames ?? loadedGames
|
||||
}
|
||||
|
||||
/// Initialize with trip and games dictionary (existing callers)
|
||||
/// Initialize with trip and games dictionary (existing callers - no custom items)
|
||||
init(trip: Trip, games: [String: RichGame]) {
|
||||
self.trip = trip
|
||||
self.providedGames = games
|
||||
self.allowCustomItems = false
|
||||
}
|
||||
|
||||
/// Initialize with just trip - games will be loaded from AppDataProvider
|
||||
/// Initialize with just trip - games will be loaded from AppDataProvider (no custom items)
|
||||
init(trip: Trip) {
|
||||
self.trip = trip
|
||||
self.providedGames = nil
|
||||
self.allowCustomItems = false
|
||||
}
|
||||
|
||||
/// Initialize for saved trip context - enables custom items
|
||||
init(trip: Trip, games: [String: RichGame], allowCustomItems: Bool) {
|
||||
self.trip = trip
|
||||
self.providedGames = games
|
||||
self.allowCustomItems = allowCustomItems
|
||||
}
|
||||
|
||||
/// Initialize for saved trip context without provided games - loads from AppDataProvider
|
||||
init(trip: Trip, allowCustomItems: Bool) {
|
||||
self.trip = trip
|
||||
self.providedGames = nil
|
||||
self.allowCustomItems = allowCustomItems
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -110,11 +137,40 @@ struct TripDetailView: View {
|
||||
.sheet(isPresented: $showProPaywall) {
|
||||
PaywallView()
|
||||
}
|
||||
.sheet(item: $addItemAnchor) { anchor in
|
||||
AddItemSheet(
|
||||
tripId: trip.id,
|
||||
anchorDay: anchor.day,
|
||||
anchorType: anchor.type,
|
||||
anchorId: anchor.anchorId,
|
||||
existingItem: nil
|
||||
) { item in
|
||||
Task { await saveCustomItem(item) }
|
||||
}
|
||||
}
|
||||
.sheet(item: $editingItem) { item in
|
||||
AddItemSheet(
|
||||
tripId: trip.id,
|
||||
anchorDay: item.anchorDay,
|
||||
anchorType: item.anchorType,
|
||||
anchorId: item.anchorId,
|
||||
existingItem: item
|
||||
) { updatedItem in
|
||||
Task { await saveCustomItem(updatedItem) }
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
checkIfSaved()
|
||||
}
|
||||
.task {
|
||||
await loadGamesIfNeeded()
|
||||
if allowCustomItems {
|
||||
await loadCustomItems()
|
||||
await setupSubscription()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
subscriptionCancellable?.cancel()
|
||||
}
|
||||
.overlay {
|
||||
if isExporting {
|
||||
@@ -330,25 +386,165 @@ struct TripDetailView: View {
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
ForEach(Array(itinerarySections.enumerated()), id: \.offset) { index, section in
|
||||
switch section {
|
||||
case .day(let dayNumber, let date, let gamesOnDay):
|
||||
DaySection(
|
||||
dayNumber: dayNumber,
|
||||
date: date,
|
||||
games: gamesOnDay
|
||||
)
|
||||
.staggeredAnimation(index: index)
|
||||
case .travel(let segment):
|
||||
TravelSection(segment: segment)
|
||||
.staggeredAnimation(index: index)
|
||||
}
|
||||
// ZStack with continuous vertical line behind all content
|
||||
ZStack(alignment: .top) {
|
||||
// Continuous vertical line down the center
|
||||
Rectangle()
|
||||
.fill(Theme.routeGold.opacity(0.4))
|
||||
.frame(width: 2)
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
// Itinerary content
|
||||
VStack(spacing: Theme.Spacing.md) {
|
||||
ForEach(Array(itinerarySections.enumerated()), id: \.offset) { index, section in
|
||||
itineraryRow(for: section, at: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build itinerary sections: group by day AND city, with travel between different cities
|
||||
@ViewBuilder
|
||||
private func itineraryRow(for section: ItinerarySection, at index: Int) -> some View {
|
||||
switch section {
|
||||
case .day(let dayNumber, let date, let gamesOnDay):
|
||||
DaySection(
|
||||
dayNumber: dayNumber,
|
||||
date: date,
|
||||
games: gamesOnDay
|
||||
)
|
||||
.staggeredAnimation(index: index)
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let itemIdString = items.first,
|
||||
let itemId = UUID(uuidString: itemIdString),
|
||||
let item = customItems.first(where: { $0.id == itemId }),
|
||||
let lastGame = gamesOnDay.last else { return false }
|
||||
Task {
|
||||
await moveItem(item, toDay: dayNumber, anchorType: .afterGame, anchorId: lastGame.game.id)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
case .travel(let segment):
|
||||
TravelSection(segment: segment)
|
||||
.staggeredAnimation(index: index)
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let itemIdString = items.first,
|
||||
let itemId = UUID(uuidString: itemIdString),
|
||||
let item = customItems.first(where: { $0.id == itemId }) else { return false }
|
||||
// Find the day for this travel segment
|
||||
let day = findDayForTravelSegment(segment)
|
||||
Task {
|
||||
await moveItem(item, toDay: day, anchorType: .afterTravel, anchorId: segment.id.uuidString)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
case .customItem(let item):
|
||||
CustomItemRow(
|
||||
item: item,
|
||||
onTap: { editingItem = item },
|
||||
onDelete: { Task { await deleteCustomItem(item) } }
|
||||
)
|
||||
.staggeredAnimation(index: index)
|
||||
.draggable(item.id.uuidString) {
|
||||
// Drag preview
|
||||
CustomItemRow(
|
||||
item: item,
|
||||
onTap: {},
|
||||
onDelete: {}
|
||||
)
|
||||
.frame(width: 300)
|
||||
.opacity(0.8)
|
||||
}
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let itemIdString = items.first,
|
||||
let itemId = UUID(uuidString: itemIdString),
|
||||
let draggedItem = customItems.first(where: { $0.id == itemId }),
|
||||
draggedItem.id != item.id else { return false }
|
||||
Task {
|
||||
await moveItem(draggedItem, toDay: item.anchorDay, anchorType: item.anchorType, anchorId: item.anchorId, beforeItem: item)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
case .addButton(let day, let anchorType, let anchorId):
|
||||
InlineAddButton {
|
||||
addItemAnchor = AddItemAnchor(day: day, type: anchorType, anchorId: anchorId)
|
||||
}
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let itemIdString = items.first,
|
||||
let itemId = UUID(uuidString: itemIdString),
|
||||
let item = customItems.first(where: { $0.id == itemId }) else { return false }
|
||||
Task {
|
||||
await moveItem(item, toDay: day, anchorType: anchorType, anchorId: anchorId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func findDayForTravelSegment(_ segment: TravelSegment) -> Int {
|
||||
// Find which day this travel segment belongs to by looking at sections
|
||||
for (index, section) in itinerarySections.enumerated() {
|
||||
if case .travel(let s) = section, s.id == segment.id {
|
||||
// Look backwards to find the day
|
||||
for i in stride(from: index - 1, through: 0, by: -1) {
|
||||
if case .day(let dayNumber, _, _) = itinerarySections[i] {
|
||||
return dayNumber
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
private func moveItem(_ item: CustomItineraryItem, toDay day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?, beforeItem: CustomItineraryItem? = nil) async {
|
||||
var updated = item
|
||||
updated.anchorDay = day
|
||||
updated.anchorType = anchorType
|
||||
updated.anchorId = anchorId
|
||||
|
||||
// Calculate sortOrder
|
||||
let itemsAtSameAnchor = customItems.filter {
|
||||
$0.anchorDay == day &&
|
||||
$0.anchorType == anchorType &&
|
||||
$0.anchorId == anchorId &&
|
||||
$0.id != item.id
|
||||
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||
|
||||
if let beforeItem = beforeItem,
|
||||
let beforeIndex = itemsAtSameAnchor.firstIndex(where: { $0.id == beforeItem.id }) {
|
||||
updated.sortOrder = beforeIndex
|
||||
// Shift other items
|
||||
for i in beforeIndex..<itemsAtSameAnchor.count {
|
||||
if var shiftItem = customItems.first(where: { $0.id == itemsAtSameAnchor[i].id }) {
|
||||
shiftItem.sortOrder = i + 1
|
||||
if let idx = customItems.firstIndex(where: { $0.id == shiftItem.id }) {
|
||||
customItems[idx] = shiftItem
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updated.sortOrder = itemsAtSameAnchor.count
|
||||
}
|
||||
|
||||
// Update local state
|
||||
if let idx = customItems.firstIndex(where: { $0.id == item.id }) {
|
||||
customItems[idx] = updated
|
||||
}
|
||||
|
||||
// Sync to CloudKit
|
||||
do {
|
||||
_ = try await CustomItemService.shared.updateItem(updated)
|
||||
print("✅ [Move] Moved item to day \(day), anchor: \(anchorType.rawValue)")
|
||||
} catch {
|
||||
print("❌ [Move] Failed to sync: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Build itinerary sections: group by day AND city, with travel, custom items, and add buttons
|
||||
private var itinerarySections: [ItinerarySection] {
|
||||
var sections: [ItinerarySection] = []
|
||||
|
||||
@@ -381,7 +577,7 @@ struct TripDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Build sections: insert travel BEFORE each section when coming from different city
|
||||
// Build sections: insert travel, custom items (only if allowed), and add buttons
|
||||
for (index, section) in dayCitySections.enumerated() {
|
||||
|
||||
// Check if we need travel BEFORE this section (coming from different city)
|
||||
@@ -394,12 +590,54 @@ struct TripDetailView: View {
|
||||
if !prevCity.isEmpty && !currentCity.isEmpty && prevCity != currentCity {
|
||||
if let travelSegment = findTravelSegment(from: prevCity, to: currentCity) {
|
||||
sections.append(.travel(travelSegment))
|
||||
|
||||
if allowCustomItems {
|
||||
// Add button after travel
|
||||
sections.append(.addButton(day: section.dayNumber, anchorType: .afterTravel, anchorId: travelSegment.id.uuidString))
|
||||
|
||||
// Custom items after this travel
|
||||
let itemsAfterTravel = customItems.filter {
|
||||
$0.anchorDay == section.dayNumber &&
|
||||
$0.anchorType == .afterTravel &&
|
||||
$0.anchorId == travelSegment.id.uuidString
|
||||
}
|
||||
for item in itemsAfterTravel {
|
||||
sections.append(.customItem(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if allowCustomItems {
|
||||
// Custom items at start of day
|
||||
let itemsAtStart = customItems.filter {
|
||||
$0.anchorDay == section.dayNumber && $0.anchorType == .startOfDay
|
||||
}
|
||||
for item in itemsAtStart {
|
||||
sections.append(.customItem(item))
|
||||
}
|
||||
}
|
||||
|
||||
// Add the day section
|
||||
sections.append(.day(dayNumber: section.dayNumber, date: section.date, games: section.games))
|
||||
|
||||
if allowCustomItems {
|
||||
// Add button after day's games
|
||||
if let lastGame = section.games.last {
|
||||
sections.append(.addButton(day: section.dayNumber, anchorType: .afterGame, anchorId: lastGame.game.id))
|
||||
|
||||
// Custom items after this game
|
||||
let itemsAfterGame = customItems.filter {
|
||||
$0.anchorDay == section.dayNumber &&
|
||||
$0.anchorType == .afterGame &&
|
||||
$0.anchorId == lastGame.game.id
|
||||
}
|
||||
for item in itemsAfterGame {
|
||||
sections.append(.customItem(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
@@ -643,6 +881,87 @@ struct TripDetailView: View {
|
||||
isSaved = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Custom Items (CloudKit persistence)
|
||||
|
||||
private func setupSubscription() async {
|
||||
// Subscribe to real-time updates for this trip
|
||||
do {
|
||||
try await CustomItemSubscriptionService.shared.subscribeToTrip(trip.id)
|
||||
|
||||
// Listen for changes and reload
|
||||
subscriptionCancellable = await CustomItemSubscriptionService.shared.changePublisher
|
||||
.filter { $0 == self.trip.id }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [self] _ in
|
||||
print("📡 [Subscription] Received update, reloading custom items...")
|
||||
Task {
|
||||
await loadCustomItems()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("📡 [Subscription] Failed to subscribe: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCustomItems() async {
|
||||
print("🔍 [CustomItems] Loading items for trip: \(trip.id)")
|
||||
do {
|
||||
let items = try await CustomItemService.shared.fetchItems(forTripId: trip.id)
|
||||
print("✅ [CustomItems] Loaded \(items.count) items from CloudKit")
|
||||
for item in items {
|
||||
print(" - \(item.title) (day \(item.anchorDay), anchor: \(item.anchorType.rawValue))")
|
||||
}
|
||||
customItems = items
|
||||
} catch {
|
||||
print("❌ [CustomItems] Failed to load: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func saveCustomItem(_ item: CustomItineraryItem) async {
|
||||
// Check if this is an update or create
|
||||
let isUpdate = customItems.contains(where: { $0.id == item.id })
|
||||
print("💾 [CustomItems] Saving item: '\(item.title)' (isUpdate: \(isUpdate))")
|
||||
print(" - tripId: \(item.tripId)")
|
||||
print(" - anchorDay: \(item.anchorDay), anchorType: \(item.anchorType.rawValue)")
|
||||
|
||||
// Update local state immediately for responsive UI
|
||||
if isUpdate {
|
||||
if let index = customItems.firstIndex(where: { $0.id == item.id }) {
|
||||
customItems[index] = item
|
||||
}
|
||||
} else {
|
||||
customItems.append(item)
|
||||
}
|
||||
|
||||
// Persist to CloudKit
|
||||
do {
|
||||
if isUpdate {
|
||||
let updated = try await CustomItemService.shared.updateItem(item)
|
||||
print("✅ [CustomItems] Updated in CloudKit: \(updated.title)")
|
||||
} else {
|
||||
let created = try await CustomItemService.shared.createItem(item)
|
||||
print("✅ [CustomItems] Created in CloudKit: \(created.title)")
|
||||
}
|
||||
} catch {
|
||||
print("❌ [CustomItems] CloudKit save failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteCustomItem(_ item: CustomItineraryItem) async {
|
||||
print("🗑️ [CustomItems] Deleting item: '\(item.title)'")
|
||||
|
||||
// Remove from local state immediately
|
||||
customItems.removeAll { $0.id == item.id }
|
||||
|
||||
// Delete from CloudKit
|
||||
do {
|
||||
try await CustomItemService.shared.deleteItem(item.id)
|
||||
print("✅ [CustomItems] Deleted from CloudKit")
|
||||
} catch {
|
||||
print("❌ [CustomItems] CloudKit delete failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Itinerary Section
|
||||
@@ -650,6 +969,43 @@ struct TripDetailView: View {
|
||||
enum ItinerarySection {
|
||||
case day(dayNumber: Int, date: Date, games: [RichGame])
|
||||
case travel(TravelSegment)
|
||||
case customItem(CustomItineraryItem)
|
||||
case addButton(day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?)
|
||||
|
||||
var isCustomItem: Bool {
|
||||
if case .customItem = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Add Item Anchor (for sheet)
|
||||
|
||||
struct AddItemAnchor: Identifiable {
|
||||
let id = UUID()
|
||||
let day: Int
|
||||
let type: CustomItineraryItem.AnchorType
|
||||
let anchorId: String?
|
||||
}
|
||||
|
||||
// MARK: - Inline Add Button
|
||||
|
||||
private struct InlineAddButton: View {
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundStyle(Theme.warmOrange.opacity(0.6))
|
||||
Text("Add")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Day Section
|
||||
@@ -783,14 +1139,8 @@ struct TravelSection: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Travel card
|
||||
VStack(spacing: 0) {
|
||||
// Top connector
|
||||
Rectangle()
|
||||
.fill(Theme.routeGold.opacity(0.4))
|
||||
.frame(width: 2, height: 16)
|
||||
|
||||
// Travel card
|
||||
VStack(spacing: 0) {
|
||||
// Main travel info
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
// Icon
|
||||
@@ -875,12 +1225,6 @@ struct TravelSection: View {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.strokeBorder(Theme.routeGold.opacity(0.3), lineWidth: 1)
|
||||
}
|
||||
|
||||
// Bottom connector
|
||||
Rectangle()
|
||||
.fill(Theme.routeGold.opacity(0.4))
|
||||
.frame(width: 2, height: 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user