Files
Sportstime/docs/plans/2026-01-15-custom-itinerary-items-implementation.md
2026-01-16 00:32:14 -06:00

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