1292 lines
37 KiB
Markdown
1292 lines
37 KiB
Markdown
# Custom Itinerary Items Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Allow users to add, edit, reorder, and delete custom items (restaurants, hotels, activities, notes) within saved trip itineraries, with CloudKit sync.
|
|
|
|
**Architecture:** Domain model with anchor-based positioning, CloudKit wrapper following CKPollVote pattern, CustomItemService actor for CRUD, SwiftData for local cache, UI integration in TripDetailView with drag/drop.
|
|
|
|
**Tech Stack:** Swift, SwiftUI, SwiftData, CloudKit, draggable/dropDestination APIs
|
|
|
|
---
|
|
|
|
## Task 1: Domain Model
|
|
|
|
**Files:**
|
|
- Create: `SportsTime/Core/Models/Domain/CustomItineraryItem.swift`
|
|
- Test: `SportsTimeTests/Domain/CustomItineraryItemTests.swift`
|
|
|
|
**Step 1: Create the domain model file**
|
|
|
|
```swift
|
|
//
|
|
// CustomItineraryItem.swift
|
|
// SportsTime
|
|
//
|
|
|
|
import Foundation
|
|
|
|
struct CustomItineraryItem: Identifiable, Codable, Hashable {
|
|
let id: UUID
|
|
let tripId: UUID
|
|
var category: ItemCategory
|
|
var title: String
|
|
var anchorType: AnchorType
|
|
var anchorId: String?
|
|
var anchorDay: Int
|
|
let createdAt: Date
|
|
var modifiedAt: Date
|
|
|
|
init(
|
|
id: UUID = UUID(),
|
|
tripId: UUID,
|
|
category: ItemCategory,
|
|
title: String,
|
|
anchorType: AnchorType = .startOfDay,
|
|
anchorId: String? = nil,
|
|
anchorDay: Int,
|
|
createdAt: Date = Date(),
|
|
modifiedAt: Date = Date()
|
|
) {
|
|
self.id = id
|
|
self.tripId = tripId
|
|
self.category = category
|
|
self.title = title
|
|
self.anchorType = anchorType
|
|
self.anchorId = anchorId
|
|
self.anchorDay = anchorDay
|
|
self.createdAt = createdAt
|
|
self.modifiedAt = modifiedAt
|
|
}
|
|
|
|
enum ItemCategory: String, Codable, CaseIterable {
|
|
case restaurant
|
|
case hotel
|
|
case activity
|
|
case note
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .restaurant: return "🍽️"
|
|
case .hotel: return "🏨"
|
|
case .activity: return "🎯"
|
|
case .note: return "📝"
|
|
}
|
|
}
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .restaurant: return "Restaurant"
|
|
case .hotel: return "Hotel"
|
|
case .activity: return "Activity"
|
|
case .note: return "Note"
|
|
}
|
|
}
|
|
|
|
var systemImage: String {
|
|
switch self {
|
|
case .restaurant: return "fork.knife"
|
|
case .hotel: return "bed.double.fill"
|
|
case .activity: return "figure.run"
|
|
case .note: return "note.text"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum AnchorType: String, Codable {
|
|
case startOfDay
|
|
case afterGame
|
|
case afterTravel
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Create basic unit tests**
|
|
|
|
```swift
|
|
//
|
|
// CustomItineraryItemTests.swift
|
|
// SportsTimeTests
|
|
//
|
|
|
|
import Testing
|
|
@testable import SportsTime
|
|
import Foundation
|
|
|
|
struct CustomItineraryItemTests {
|
|
|
|
@Test("Item initializes with default values")
|
|
func item_InitializesWithDefaults() {
|
|
let tripId = UUID()
|
|
let item = CustomItineraryItem(
|
|
tripId: tripId,
|
|
category: .restaurant,
|
|
title: "Joe's BBQ",
|
|
anchorDay: 1
|
|
)
|
|
|
|
#expect(item.tripId == tripId)
|
|
#expect(item.category == .restaurant)
|
|
#expect(item.title == "Joe's BBQ")
|
|
#expect(item.anchorType == .startOfDay)
|
|
#expect(item.anchorId == nil)
|
|
#expect(item.anchorDay == 1)
|
|
}
|
|
|
|
@Test("Item category has correct icons")
|
|
func category_HasCorrectIcons() {
|
|
#expect(CustomItineraryItem.ItemCategory.restaurant.icon == "🍽️")
|
|
#expect(CustomItineraryItem.ItemCategory.hotel.icon == "🏨")
|
|
#expect(CustomItineraryItem.ItemCategory.activity.icon == "🎯")
|
|
#expect(CustomItineraryItem.ItemCategory.note.icon == "📝")
|
|
}
|
|
|
|
@Test("Item is Codable")
|
|
func item_IsCodable() throws {
|
|
let item = CustomItineraryItem(
|
|
tripId: UUID(),
|
|
category: .hotel,
|
|
title: "Hilton Downtown",
|
|
anchorType: .afterGame,
|
|
anchorId: "game_123",
|
|
anchorDay: 2
|
|
)
|
|
|
|
let encoded = try JSONEncoder().encode(item)
|
|
let decoded = try JSONDecoder().decode(CustomItineraryItem.self, from: encoded)
|
|
|
|
#expect(decoded.id == item.id)
|
|
#expect(decoded.title == item.title)
|
|
#expect(decoded.anchorType == .afterGame)
|
|
#expect(decoded.anchorId == "game_123")
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Run tests to verify**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/CustomItineraryItemTests test`
|
|
Expected: PASS
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Core/Models/Domain/CustomItineraryItem.swift SportsTimeTests/Domain/CustomItineraryItemTests.swift
|
|
git commit -m "feat(models): add CustomItineraryItem domain model"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: CloudKit Wrapper
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Core/Models/CloudKit/CKModels.swift` (add at end)
|
|
|
|
**Step 1: Add CKRecordType constant**
|
|
|
|
Find the `CKRecordType` extension and add:
|
|
|
|
```swift
|
|
static let customItineraryItem: CKRecordType = "CustomItineraryItem"
|
|
```
|
|
|
|
**Step 2: Add CKCustomItineraryItem struct**
|
|
|
|
Add at the end of CKModels.swift:
|
|
|
|
```swift
|
|
// MARK: - CKCustomItineraryItem
|
|
|
|
struct CKCustomItineraryItem {
|
|
static let itemIdKey = "itemId"
|
|
static let tripIdKey = "tripId"
|
|
static let categoryKey = "category"
|
|
static let titleKey = "title"
|
|
static let anchorTypeKey = "anchorType"
|
|
static let anchorIdKey = "anchorId"
|
|
static let anchorDayKey = "anchorDay"
|
|
static let createdAtKey = "createdAt"
|
|
static let modifiedAtKey = "modifiedAt"
|
|
|
|
let record: CKRecord
|
|
|
|
init(record: CKRecord) {
|
|
self.record = record
|
|
}
|
|
|
|
init(item: CustomItineraryItem) {
|
|
let record = CKRecord(
|
|
recordType: CKRecordType.customItineraryItem,
|
|
recordID: CKRecord.ID(recordName: item.id.uuidString)
|
|
)
|
|
record[CKCustomItineraryItem.itemIdKey] = item.id.uuidString
|
|
record[CKCustomItineraryItem.tripIdKey] = item.tripId.uuidString
|
|
record[CKCustomItineraryItem.categoryKey] = item.category.rawValue
|
|
record[CKCustomItineraryItem.titleKey] = item.title
|
|
record[CKCustomItineraryItem.anchorTypeKey] = item.anchorType.rawValue
|
|
record[CKCustomItineraryItem.anchorIdKey] = item.anchorId
|
|
record[CKCustomItineraryItem.anchorDayKey] = item.anchorDay
|
|
record[CKCustomItineraryItem.createdAtKey] = item.createdAt
|
|
record[CKCustomItineraryItem.modifiedAtKey] = item.modifiedAt
|
|
self.record = record
|
|
}
|
|
|
|
func toItem() -> CustomItineraryItem? {
|
|
guard let itemIdString = record[CKCustomItineraryItem.itemIdKey] as? String,
|
|
let itemId = UUID(uuidString: itemIdString),
|
|
let tripIdString = record[CKCustomItineraryItem.tripIdKey] as? String,
|
|
let tripId = UUID(uuidString: tripIdString),
|
|
let categoryString = record[CKCustomItineraryItem.categoryKey] as? String,
|
|
let category = CustomItineraryItem.ItemCategory(rawValue: categoryString),
|
|
let title = record[CKCustomItineraryItem.titleKey] as? String,
|
|
let anchorTypeString = record[CKCustomItineraryItem.anchorTypeKey] as? String,
|
|
let anchorType = CustomItineraryItem.AnchorType(rawValue: anchorTypeString),
|
|
let anchorDay = record[CKCustomItineraryItem.anchorDayKey] as? Int,
|
|
let createdAt = record[CKCustomItineraryItem.createdAtKey] as? Date,
|
|
let modifiedAt = record[CKCustomItineraryItem.modifiedAtKey] as? Date
|
|
else { return nil }
|
|
|
|
let anchorId = record[CKCustomItineraryItem.anchorIdKey] as? String
|
|
|
|
return CustomItineraryItem(
|
|
id: itemId,
|
|
tripId: tripId,
|
|
category: category,
|
|
title: title,
|
|
anchorType: anchorType,
|
|
anchorId: anchorId,
|
|
anchorDay: anchorDay,
|
|
createdAt: createdAt,
|
|
modifiedAt: modifiedAt
|
|
)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Build to verify**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
Expected: BUILD SUCCEEDED
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Core/Models/CloudKit/CKModels.swift
|
|
git commit -m "feat(cloudkit): add CKCustomItineraryItem wrapper"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: CustomItemService
|
|
|
|
**Files:**
|
|
- Create: `SportsTime/Core/Services/CustomItemService.swift`
|
|
|
|
**Step 1: Create the service**
|
|
|
|
```swift
|
|
//
|
|
// CustomItemService.swift
|
|
// SportsTime
|
|
//
|
|
// CloudKit service for custom itinerary items
|
|
//
|
|
|
|
import Foundation
|
|
import CloudKit
|
|
|
|
actor CustomItemService {
|
|
static let shared = CustomItemService()
|
|
|
|
private let container: CKContainer
|
|
private let publicDatabase: CKDatabase
|
|
|
|
private init() {
|
|
self.container = CKContainer(identifier: "iCloud.com.sportstime.app")
|
|
self.publicDatabase = container.publicCloudDatabase
|
|
}
|
|
|
|
// MARK: - CRUD Operations
|
|
|
|
func createItem(_ item: CustomItineraryItem) async throws -> CustomItineraryItem {
|
|
let ckItem = CKCustomItineraryItem(item: item)
|
|
|
|
do {
|
|
try await publicDatabase.save(ckItem.record)
|
|
return item
|
|
} catch let error as CKError {
|
|
throw mapCloudKitError(error)
|
|
} catch {
|
|
throw CustomItemError.unknown(error)
|
|
}
|
|
}
|
|
|
|
func updateItem(_ item: CustomItineraryItem) async throws -> CustomItineraryItem {
|
|
// Fetch existing record to get changeTag
|
|
let recordID = CKRecord.ID(recordName: item.id.uuidString)
|
|
let existingRecord: CKRecord
|
|
|
|
do {
|
|
existingRecord = try await publicDatabase.record(for: recordID)
|
|
} catch let error as CKError {
|
|
throw mapCloudKitError(error)
|
|
} catch {
|
|
throw CustomItemError.unknown(error)
|
|
}
|
|
|
|
// Update fields
|
|
let now = Date()
|
|
existingRecord[CKCustomItineraryItem.categoryKey] = item.category.rawValue
|
|
existingRecord[CKCustomItineraryItem.titleKey] = item.title
|
|
existingRecord[CKCustomItineraryItem.anchorTypeKey] = item.anchorType.rawValue
|
|
existingRecord[CKCustomItineraryItem.anchorIdKey] = item.anchorId
|
|
existingRecord[CKCustomItineraryItem.anchorDayKey] = item.anchorDay
|
|
existingRecord[CKCustomItineraryItem.modifiedAtKey] = now
|
|
|
|
do {
|
|
try await publicDatabase.save(existingRecord)
|
|
var updatedItem = item
|
|
updatedItem.modifiedAt = now
|
|
return updatedItem
|
|
} catch let error as CKError {
|
|
throw mapCloudKitError(error)
|
|
} catch {
|
|
throw CustomItemError.unknown(error)
|
|
}
|
|
}
|
|
|
|
func deleteItem(_ itemId: UUID) async throws {
|
|
let recordID = CKRecord.ID(recordName: itemId.uuidString)
|
|
|
|
do {
|
|
try await publicDatabase.deleteRecord(withID: recordID)
|
|
} catch let error as CKError {
|
|
if error.code != .unknownItem {
|
|
throw mapCloudKitError(error)
|
|
}
|
|
// Item already deleted - ignore
|
|
} catch {
|
|
throw CustomItemError.unknown(error)
|
|
}
|
|
}
|
|
|
|
func fetchItems(forTripId tripId: UUID) async throws -> [CustomItineraryItem] {
|
|
let predicate = NSPredicate(
|
|
format: "%K == %@",
|
|
CKCustomItineraryItem.tripIdKey,
|
|
tripId.uuidString
|
|
)
|
|
let query = CKQuery(recordType: CKRecordType.customItineraryItem, predicate: predicate)
|
|
|
|
do {
|
|
let (results, _) = try await publicDatabase.records(matching: query)
|
|
|
|
return results.compactMap { result in
|
|
guard case .success(let record) = result.1 else { return nil }
|
|
return CKCustomItineraryItem(record: record).toItem()
|
|
}.sorted { $0.anchorDay < $1.anchorDay }
|
|
} catch let error as CKError {
|
|
throw mapCloudKitError(error)
|
|
} catch {
|
|
throw CustomItemError.unknown(error)
|
|
}
|
|
}
|
|
|
|
// MARK: - Error Mapping
|
|
|
|
private func mapCloudKitError(_ error: CKError) -> CustomItemError {
|
|
switch error.code {
|
|
case .notAuthenticated:
|
|
return .notSignedIn
|
|
case .networkUnavailable, .networkFailure:
|
|
return .networkUnavailable
|
|
case .unknownItem:
|
|
return .itemNotFound
|
|
default:
|
|
return .unknown(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Errors
|
|
|
|
enum CustomItemError: Error, LocalizedError {
|
|
case notSignedIn
|
|
case itemNotFound
|
|
case networkUnavailable
|
|
case unknown(Error)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .notSignedIn:
|
|
return "Please sign in to iCloud to use custom items."
|
|
case .itemNotFound:
|
|
return "Item not found. It may have been deleted."
|
|
case .networkUnavailable:
|
|
return "Unable to connect. Please check your internet connection."
|
|
case .unknown(let error):
|
|
return "An error occurred: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Build to verify**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
Expected: BUILD SUCCEEDED
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Core/Services/CustomItemService.swift
|
|
git commit -m "feat(services): add CustomItemService for CloudKit CRUD"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: SwiftData Local Model
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Core/Models/Local/SavedTrip.swift`
|
|
|
|
**Step 1: Add LocalCustomItem model**
|
|
|
|
Add after the `TripVote` model:
|
|
|
|
```swift
|
|
// MARK: - Local Custom Item (Cache)
|
|
|
|
@Model
|
|
final class LocalCustomItem {
|
|
@Attribute(.unique) var id: UUID
|
|
var tripId: UUID
|
|
var category: String
|
|
var title: String
|
|
var anchorType: String
|
|
var anchorId: String?
|
|
var anchorDay: Int
|
|
var createdAt: Date
|
|
var modifiedAt: Date
|
|
var pendingSync: Bool // True if needs to sync to CloudKit
|
|
|
|
init(
|
|
id: UUID = UUID(),
|
|
tripId: UUID,
|
|
category: CustomItineraryItem.ItemCategory,
|
|
title: String,
|
|
anchorType: CustomItineraryItem.AnchorType = .startOfDay,
|
|
anchorId: String? = nil,
|
|
anchorDay: Int,
|
|
createdAt: Date = Date(),
|
|
modifiedAt: Date = Date(),
|
|
pendingSync: Bool = false
|
|
) {
|
|
self.id = id
|
|
self.tripId = tripId
|
|
self.category = category.rawValue
|
|
self.title = title
|
|
self.anchorType = anchorType.rawValue
|
|
self.anchorId = anchorId
|
|
self.anchorDay = anchorDay
|
|
self.createdAt = createdAt
|
|
self.modifiedAt = modifiedAt
|
|
self.pendingSync = pendingSync
|
|
}
|
|
|
|
var toItem: CustomItineraryItem? {
|
|
guard let category = CustomItineraryItem.ItemCategory(rawValue: category),
|
|
let anchorType = CustomItineraryItem.AnchorType(rawValue: anchorType)
|
|
else { return nil }
|
|
|
|
return CustomItineraryItem(
|
|
id: id,
|
|
tripId: tripId,
|
|
category: category,
|
|
title: title,
|
|
anchorType: anchorType,
|
|
anchorId: anchorId,
|
|
anchorDay: anchorDay,
|
|
createdAt: createdAt,
|
|
modifiedAt: modifiedAt
|
|
)
|
|
}
|
|
|
|
static func from(_ item: CustomItineraryItem, pendingSync: Bool = false) -> LocalCustomItem {
|
|
LocalCustomItem(
|
|
id: item.id,
|
|
tripId: item.tripId,
|
|
category: item.category,
|
|
title: item.title,
|
|
anchorType: item.anchorType,
|
|
anchorId: item.anchorId,
|
|
anchorDay: item.anchorDay,
|
|
createdAt: item.createdAt,
|
|
modifiedAt: item.modifiedAt,
|
|
pendingSync: pendingSync
|
|
)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Build to verify**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
Expected: BUILD SUCCEEDED
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Core/Models/Local/SavedTrip.swift
|
|
git commit -m "feat(models): add LocalCustomItem SwiftData model"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: AddItemSheet UI
|
|
|
|
**Files:**
|
|
- Create: `SportsTime/Features/Trip/Views/AddItemSheet.swift`
|
|
|
|
**Step 1: Create the sheet**
|
|
|
|
```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 }
|
|
}
|
|
```
|
|
|
|
**Step 2: Build to verify**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
Expected: BUILD SUCCEEDED
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/AddItemSheet.swift
|
|
git commit -m "feat(ui): add AddItemSheet for custom itinerary items"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: CustomItemRow UI
|
|
|
|
**Files:**
|
|
- Create: `SportsTime/Features/Trip/Views/CustomItemRow.swift`
|
|
|
|
**Step 1: Create the row component**
|
|
|
|
```swift
|
|
//
|
|
// 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
|
|
|
|
/*
|
|
REORDERING OPTIONS (for future reference):
|
|
1. Edit mode toggle - User taps "Edit" to show drag handles, then "Done" (like Reminders)
|
|
2. Long-press to drag - No edit mode, just long-press and drag
|
|
3. Always show handles - Custom items always have visible drag handle (CURRENT CHOICE)
|
|
*/
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
// Category icon
|
|
Text(item.category.icon)
|
|
.font(.title3)
|
|
|
|
// Title
|
|
Text(item.title)
|
|
.font(.subheadline)
|
|
.lineLimit(2)
|
|
|
|
Spacer()
|
|
|
|
// Drag handle - always visible
|
|
Image(systemName: "line.3.horizontal")
|
|
.foregroundStyle(.secondary)
|
|
.font(.subheadline)
|
|
}
|
|
.padding(.horizontal, Theme.Spacing.md)
|
|
.padding(.vertical, Theme.Spacing.sm)
|
|
.background(Theme.warmOrange.opacity(0.08))
|
|
.cornerRadius(8)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
onTap()
|
|
}
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
|
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()
|
|
}
|
|
```
|
|
|
|
**Step 2: Build to verify**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
Expected: BUILD SUCCEEDED
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/CustomItemRow.swift
|
|
git commit -m "feat(ui): add CustomItemRow with drag handle and swipe-to-delete"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: TripDetailView Integration - Part 1 (State & Data Loading)
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Trip/Views/TripDetailView.swift`
|
|
|
|
**Step 1: Add state properties**
|
|
|
|
Add these state properties near the existing `@State` declarations:
|
|
|
|
```swift
|
|
@State private var customItems: [CustomItineraryItem] = []
|
|
@State private var showAddItemSheet = false
|
|
@State private var editingItem: CustomItineraryItem?
|
|
@State private var addItemAnchor: (day: Int, type: CustomItineraryItem.AnchorType, id: String?)? = nil
|
|
```
|
|
|
|
**Step 2: Add loadCustomItems function**
|
|
|
|
Add this function in the view:
|
|
|
|
```swift
|
|
private func loadCustomItems() async {
|
|
guard let tripId = trip.id as UUID? else { return }
|
|
do {
|
|
customItems = try await CustomItemService.shared.fetchItems(forTripId: tripId)
|
|
} catch {
|
|
print("Failed to load custom items: \(error)")
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Add task modifier to load items**
|
|
|
|
In the body, add a `.task` modifier after the existing `.onAppear`:
|
|
|
|
```swift
|
|
.task {
|
|
await loadCustomItems()
|
|
}
|
|
```
|
|
|
|
**Step 4: Build to verify**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
Expected: BUILD SUCCEEDED
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/TripDetailView.swift
|
|
git commit -m "feat(ui): add custom items state and loading to TripDetailView"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: TripDetailView Integration - Part 2 (Inline Add Buttons)
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Trip/Views/TripDetailView.swift`
|
|
|
|
**Step 1: Create InlineAddButton component**
|
|
|
|
Add this private struct inside or near TripDetailView:
|
|
|
|
```swift
|
|
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)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Update ItinerarySection enum**
|
|
|
|
Find the `ItinerarySection` enum and add a new case:
|
|
|
|
```swift
|
|
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?)
|
|
}
|
|
```
|
|
|
|
**Step 3: Build to verify**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
Expected: BUILD SUCCEEDED (may have warnings about unhandled cases - fix in next task)
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/TripDetailView.swift
|
|
git commit -m "feat(ui): add InlineAddButton and update ItinerarySection enum"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: TripDetailView Integration - Part 3 (Rendering Custom Items)
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Trip/Views/TripDetailView.swift`
|
|
|
|
**Step 1: Update itinerarySections to include custom items and add buttons**
|
|
|
|
Replace the `itinerarySections` computed property to interleave custom items and add buttons. This is complex - the key is to:
|
|
1. For each day section, insert custom items anchored to that day
|
|
2. After each game/travel, insert any custom items anchored to it
|
|
3. Insert add buttons in the gaps
|
|
|
|
```swift
|
|
private var itinerarySections: [ItinerarySection] {
|
|
var sections: [ItinerarySection] = []
|
|
var dayCitySections: [(dayNumber: Int, date: Date, city: String, games: [RichGame])] = []
|
|
let days = tripDays
|
|
|
|
for (index, dayDate) in days.enumerated() {
|
|
let dayNum = index + 1
|
|
let gamesOnDay = gamesOn(date: dayDate)
|
|
|
|
guard !gamesOnDay.isEmpty else { continue }
|
|
|
|
var gamesByCity: [(city: String, games: [RichGame])] = []
|
|
for game in gamesOnDay {
|
|
let city = game.stadium.city
|
|
if let lastIndex = gamesByCity.indices.last, gamesByCity[lastIndex].city == city {
|
|
gamesByCity[lastIndex].games.append(game)
|
|
} else {
|
|
gamesByCity.append((city, [game]))
|
|
}
|
|
}
|
|
|
|
for cityGroup in gamesByCity {
|
|
dayCitySections.append((dayNum, dayDate, cityGroup.city, cityGroup.games))
|
|
}
|
|
}
|
|
|
|
for (index, section) in dayCitySections.enumerated() {
|
|
// Travel before this section
|
|
if index > 0 {
|
|
let prevSection = dayCitySections[index - 1]
|
|
if prevSection.city != section.city {
|
|
if let travelSegment = findTravelSegment(from: prevSection.city, to: section.city) {
|
|
sections.append(.travel(travelSegment))
|
|
// 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))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add button at start of day (before games)
|
|
sections.append(.addButton(day: section.dayNumber, anchorType: .startOfDay, anchorId: nil))
|
|
|
|
// 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))
|
|
}
|
|
|
|
// Day section
|
|
sections.append(.day(dayNumber: section.dayNumber, date: section.date, games: section.games))
|
|
|
|
// 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
|
|
}
|
|
```
|
|
|
|
**Step 2: Update the ForEach in itinerarySection to handle new cases**
|
|
|
|
Find where `itinerarySections` is rendered and update the switch:
|
|
|
|
```swift
|
|
ForEach(Array(itinerarySections.enumerated()), id: \.offset) { index, section in
|
|
switch section {
|
|
case .day(let dayNumber, let date, let games):
|
|
DaySection(
|
|
dayNumber: dayNumber,
|
|
date: date,
|
|
games: games,
|
|
trip: trip,
|
|
colorScheme: colorScheme
|
|
)
|
|
.staggeredAnimation(index: index)
|
|
case .travel(let segment):
|
|
TravelSection(segment: segment)
|
|
.staggeredAnimation(index: index)
|
|
case .customItem(let item):
|
|
CustomItemRow(
|
|
item: item,
|
|
onTap: { editingItem = item },
|
|
onDelete: { Task { await deleteItem(item) } }
|
|
)
|
|
.staggeredAnimation(index: index)
|
|
case .addButton(let day, let anchorType, let anchorId):
|
|
InlineAddButton {
|
|
addItemAnchor = (day, anchorType, anchorId)
|
|
showAddItemSheet = true
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Build to verify**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
Expected: BUILD SUCCEEDED (may need to add missing functions in next task)
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/TripDetailView.swift
|
|
git commit -m "feat(ui): render custom items and add buttons in itinerary"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: TripDetailView Integration - Part 4 (CRUD Operations & Sheets)
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Trip/Views/TripDetailView.swift`
|
|
|
|
**Step 1: Add CRUD helper functions**
|
|
|
|
```swift
|
|
private func saveItem(_ item: CustomItineraryItem) async {
|
|
do {
|
|
if customItems.contains(where: { $0.id == item.id }) {
|
|
// Update existing
|
|
let updated = try await CustomItemService.shared.updateItem(item)
|
|
if let index = customItems.firstIndex(where: { $0.id == updated.id }) {
|
|
customItems[index] = updated
|
|
}
|
|
} else {
|
|
// Create new
|
|
let created = try await CustomItemService.shared.createItem(item)
|
|
customItems.append(created)
|
|
}
|
|
} catch {
|
|
print("Failed to save item: \(error)")
|
|
}
|
|
}
|
|
|
|
private func deleteItem(_ item: CustomItineraryItem) async {
|
|
do {
|
|
try await CustomItemService.shared.deleteItem(item.id)
|
|
customItems.removeAll { $0.id == item.id }
|
|
} catch {
|
|
print("Failed to delete item: \(error)")
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Add sheet modifiers**
|
|
|
|
Add these modifiers to the view body:
|
|
|
|
```swift
|
|
.sheet(isPresented: $showAddItemSheet) {
|
|
if let anchor = addItemAnchor {
|
|
AddItemSheet(
|
|
tripId: trip.id,
|
|
anchorDay: anchor.day,
|
|
anchorType: anchor.type,
|
|
anchorId: anchor.id,
|
|
existingItem: nil
|
|
) { item in
|
|
Task { await saveItem(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 saveItem(updatedItem) }
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Build to verify**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
Expected: BUILD SUCCEEDED
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/TripDetailView.swift
|
|
git commit -m "feat(ui): add CRUD operations and sheets for custom items"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Drag and Drop (Optional Enhancement)
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Trip/Views/TripDetailView.swift`
|
|
- Modify: `SportsTime/Features/Trip/Views/CustomItemRow.swift`
|
|
|
|
**Note:** This task adds drag-and-drop reordering. It can be deferred if basic add/edit/delete is sufficient for now.
|
|
|
|
**Step 1: Make CustomItemRow draggable**
|
|
|
|
Update CustomItemRow to add `.draggable`:
|
|
|
|
```swift
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
// ... existing content
|
|
}
|
|
// ... existing modifiers
|
|
.draggable(item.id.uuidString) {
|
|
// Drag preview
|
|
HStack {
|
|
Text(item.category.icon)
|
|
Text(item.title)
|
|
}
|
|
.padding(8)
|
|
.background(Theme.cardBackground(.dark))
|
|
.cornerRadius(8)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Add drop destinations to add buttons**
|
|
|
|
Update InlineAddButton:
|
|
|
|
```swift
|
|
private struct InlineAddButton: View {
|
|
let day: Int
|
|
let anchorType: CustomItineraryItem.AnchorType
|
|
let anchorId: String?
|
|
let onAdd: () -> Void
|
|
let onDrop: (CustomItineraryItem.AnchorType, String?, Int) -> Void
|
|
|
|
var body: some View {
|
|
Button(action: onAdd) {
|
|
// ... existing content
|
|
}
|
|
.dropDestination(for: String.self) { items, _ in
|
|
guard let itemIdString = items.first else { return false }
|
|
onDrop(anchorType, anchorId, day)
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Implement move logic**
|
|
|
|
Add function to handle drops:
|
|
|
|
```swift
|
|
private func moveItem(itemId: String, toAnchorType: CustomItineraryItem.AnchorType, toAnchorId: String?, toDay: Int) async {
|
|
guard let uuid = UUID(uuidString: itemId),
|
|
var item = customItems.first(where: { $0.id == uuid }) else { return }
|
|
|
|
item.anchorType = toAnchorType
|
|
item.anchorId = toAnchorId
|
|
item.anchorDay = toDay
|
|
item.modifiedAt = Date()
|
|
|
|
await saveItem(item)
|
|
}
|
|
```
|
|
|
|
**Step 4: Build and test**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
Expected: BUILD SUCCEEDED
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/TripDetailView.swift SportsTime/Features/Trip/Views/CustomItemRow.swift
|
|
git commit -m "feat(ui): add drag-and-drop reordering for custom items"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Final Integration Test
|
|
|
|
**Step 1: Run full test suite**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test`
|
|
Expected: All tests PASS
|
|
|
|
**Step 2: Manual testing checklist**
|
|
|
|
- [ ] Open a saved trip
|
|
- [ ] Tap "+ Add" button between items
|
|
- [ ] Select category and enter title
|
|
- [ ] Verify item appears in correct position
|
|
- [ ] Tap item to edit
|
|
- [ ] Swipe left to delete
|
|
- [ ] (If drag implemented) Drag item to new position
|
|
- [ ] Close and reopen trip - items persist
|
|
- [ ] Test on second device - items sync via CloudKit
|
|
|
|
**Step 3: Final commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: complete custom itinerary items feature"
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
| Task | Description | Files |
|
|
|------|-------------|-------|
|
|
| 1 | Domain model | `CustomItineraryItem.swift`, tests |
|
|
| 2 | CloudKit wrapper | `CKModels.swift` |
|
|
| 3 | Service layer | `CustomItemService.swift` |
|
|
| 4 | SwiftData model | `SavedTrip.swift` |
|
|
| 5 | Add/Edit sheet | `AddItemSheet.swift` |
|
|
| 6 | Item row component | `CustomItemRow.swift` |
|
|
| 7-10 | TripDetailView integration | `TripDetailView.swift` |
|
|
| 11 | Drag and drop (optional) | Multiple |
|
|
| 12 | Integration testing | - |
|