Files
Sportstime/SportsTime/Features/Trip/Views/AddItemSheet.swift
Trey t 495ef88303 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>
2026-01-16 00:31:44 -06:00

150 lines
4.4 KiB
Swift

//
// 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 }
}