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:
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user