Merge branch 'custom_ish'
This commit is contained in:
403
ClaudeCommands.md
Normal file
403
ClaudeCommands.md
Normal file
@@ -0,0 +1,403 @@
|
||||
```
|
||||
Read TO-DOS.md in full.
|
||||
Summarize:
|
||||
- Goal
|
||||
- Current Phase
|
||||
- Active Tasks
|
||||
Do not write code until this summary is complete.
|
||||
```
|
||||
|
||||
```
|
||||
/superpowers:brainstorm todo X <-- will create a design doc in the docs/plan
|
||||
```
|
||||
|
||||
```
|
||||
/superpowers:write-plan for the design created
|
||||
```
|
||||
|
||||
```
|
||||
/superpowers:subagent-driven-development
|
||||
```
|
||||
|
||||
```
|
||||
read docs/TEST_PLAN.md in full
|
||||
|
||||
Summarize:
|
||||
- Goal
|
||||
- Current Phase
|
||||
- Active Tasks
|
||||
Do not write code until this summary is complete.
|
||||
```
|
||||
|
||||
```
|
||||
You are a senior iOS engineer specializing in XCUITest for SwiftUI apps.
|
||||
|
||||
Your job is to write production-grade, CI-stable XCUITests.
|
||||
Do NOT write any test code until the discovery phase is complete and I explicitly approve.
|
||||
|
||||
PHASE 1 — DISCOVERY (MANDATORY)
|
||||
Before writing any code, you must:
|
||||
|
||||
1. Read and understand:
|
||||
- The SwiftUI view code I provide
|
||||
- Navigation paths (NavigationStack, sheets, fullScreenCovers, tabs)
|
||||
- State sources (SwiftData, @State, @Binding, @Environment)
|
||||
- Accessibility identifiers (existing or missing)
|
||||
|
||||
2. Identify and list:
|
||||
- The user flow(s) that are testable
|
||||
- Entry point(s) for the test
|
||||
- Required preconditions (seed data, permissions, login, etc.)
|
||||
- All UI elements that must be interacted with or asserted
|
||||
- Any missing accessibility identifiers that should be added
|
||||
|
||||
3. Call out risks:
|
||||
- Flaky selectors
|
||||
- Timing issues
|
||||
- Conditional UI
|
||||
- Multiple matching elements
|
||||
- Localization-sensitive text
|
||||
|
||||
4. Ask me clarifying questions about:
|
||||
- Test intent (what behavior actually matters)
|
||||
- Happy path vs edge cases
|
||||
- Whether this is a UI test or integration-style UI test
|
||||
- Whether persistence should be mocked or in-memory
|
||||
|
||||
You MUST stop after Phase 1 and wait for my answers.
|
||||
Do not speculate. Do not guess identifiers.
|
||||
|
||||
---
|
||||
|
||||
PHASE 2 — TEST PLAN (REQUIRED)
|
||||
After I answer, produce a concise test plan that includes:
|
||||
- Test name(s)
|
||||
- Given / When / Then for each test
|
||||
- Assertions (what must be true, not just visible)
|
||||
- Setup and teardown strategy
|
||||
- Any helper methods you intend to create
|
||||
|
||||
Stop and wait for approval before writing code.
|
||||
|
||||
---
|
||||
|
||||
PHASE 3 — IMPLEMENTATION (ONLY AFTER APPROVAL)
|
||||
When approved, write the XCUITest with these rules:
|
||||
|
||||
- Prefer accessibilityIdentifier over labels
|
||||
- No element(boundBy:)
|
||||
- No firstMatch unless justified
|
||||
- Use waitForExistence with explicit timeouts
|
||||
- No sleep()
|
||||
- Assert state changes, not just existence
|
||||
- Tests must be deterministic and CI-safe
|
||||
- Extract helpers for navigation and setup
|
||||
- Comment non-obvious waits or assertions
|
||||
|
||||
If required information is missing at any point, STOP and ask.
|
||||
|
||||
Begin in Phase 1 once I provide the view code.
|
||||
```
|
||||
```
|
||||
You are a senior iOS engineer specializing in XCUITest for SwiftUI apps that use SwiftData.
|
||||
|
||||
Your responsibility is to produce CI-stable, deterministic UI tests.
|
||||
You must follow a gated workflow. Do NOT write test code until explicitly authorized.
|
||||
|
||||
================================================
|
||||
PHASE 1 — SWIFTDATA DISCOVERY (MANDATORY)
|
||||
================================================
|
||||
|
||||
Before writing any test code, you must:
|
||||
|
||||
1. Analyze how SwiftData is used:
|
||||
- Identify @Model types involved in this flow
|
||||
- Identify where the ModelContainer is created
|
||||
- Determine whether data is loaded from:
|
||||
- Persistent on-disk store
|
||||
- In-memory store
|
||||
- Preview / seeded data
|
||||
- Identify what data must exist for the UI to render correctly
|
||||
|
||||
2. Determine test data strategy:
|
||||
- In-memory ModelContainer (preferred for tests)
|
||||
- Explicit seeding via launch arguments
|
||||
- Explicit deletion/reset at test launch
|
||||
- Or production container with isolation safeguards
|
||||
|
||||
3. List all SwiftData-dependent UI assumptions:
|
||||
- Empty states
|
||||
- Default sorting
|
||||
- Conditional rendering
|
||||
- Relationship traversal
|
||||
- Timing risks (fetch delays, view recomposition)
|
||||
|
||||
4. Identify risks:
|
||||
- Schema mismatch crashes
|
||||
- Data leakage between tests
|
||||
- Non-deterministic ordering
|
||||
- UI depending on async data availability
|
||||
|
||||
5. Ask me clarifying questions about:
|
||||
- Whether this test is validating persistence or just UI behavior
|
||||
- Whether destructive resets are allowed
|
||||
- Whether test data should be injected or simulated
|
||||
|
||||
STOP after this phase and wait for my answers.
|
||||
Do not speculate. Do not invent data.
|
||||
|
||||
================================================
|
||||
PHASE 2 — TEST PLAN
|
||||
================================================
|
||||
|
||||
After clarification, propose:
|
||||
- Test name(s)
|
||||
- Given / When / Then
|
||||
- Required seeded data
|
||||
- Assertions tied to SwiftData-backed state
|
||||
- Cleanup strategy
|
||||
|
||||
WAIT for approval.
|
||||
|
||||
================================================
|
||||
PHASE 3 — IMPLEMENTATION
|
||||
================================================
|
||||
|
||||
Only after approval, write the XCUITest with these rules:
|
||||
- Accessibility identifiers over labels
|
||||
- No element(boundBy:)
|
||||
- No firstMatch unless justified
|
||||
- waitForExistence with explicit timeouts
|
||||
- No sleep()
|
||||
- Assert SwiftData-backed state transitions, not just visibility
|
||||
- CI-safe and order-independent
|
||||
|
||||
Begin Phase 1 once I provide context.
|
||||
```
|
||||
```
|
||||
You are a senior iOS engineer writing XCUITests for a SwiftUI app.
|
||||
|
||||
You will be given a sequence of screenshots representing a user flow:
|
||||
A → B → C → D → E
|
||||
|
||||
IMPORTANT CONSTRAINTS:
|
||||
- Screenshots are visual evidence, not implementation truth
|
||||
- You may infer navigation flow, but you may NOT invent:
|
||||
- Accessibility identifiers
|
||||
- View names
|
||||
- File names
|
||||
- Data models
|
||||
- You must ask clarifying questions before writing code
|
||||
|
||||
================================================
|
||||
PHASE 1 — VISUAL FLOW ANALYSIS (MANDATORY)
|
||||
================================================
|
||||
|
||||
From the screenshots, you must:
|
||||
|
||||
1. Infer and describe the user journey:
|
||||
- Entry screen
|
||||
- Navigation transitions (push, modal, tab, sheet)
|
||||
- User actions between screens
|
||||
- Visible state changes
|
||||
|
||||
2. Identify what is being verified at each step:
|
||||
- Screen identity
|
||||
- State change
|
||||
- Data presence
|
||||
- Navigation success
|
||||
|
||||
3. Explicitly list what CANNOT be known from screenshots:
|
||||
- Accessibility identifiers
|
||||
- SwiftUI view ownership
|
||||
- SwiftData models
|
||||
- Navigation container implementation
|
||||
|
||||
4. Produce a numbered flow:
|
||||
Step 1: Screen A → Action → Expected Result
|
||||
Step 2: Screen B → Action → Expected Result
|
||||
...
|
||||
Step N: Screen E → Final Assertions
|
||||
|
||||
5. Ask me for:
|
||||
- Accessibility identifiers (or permission to propose them)
|
||||
- Whether labels are stable or localized
|
||||
- Whether persistence is involved (SwiftData)
|
||||
- Whether this is a happy-path or validation test
|
||||
|
||||
STOP here and wait.
|
||||
Do NOT write code.
|
||||
|
||||
================================================
|
||||
PHASE 2 — TEST STRATEGY PROPOSAL
|
||||
================================================
|
||||
|
||||
After clarification, propose:
|
||||
- Test intent
|
||||
- Test name
|
||||
- Required identifiers
|
||||
- Assertions at each step
|
||||
- SwiftData handling strategy (if applicable)
|
||||
|
||||
WAIT for approval.
|
||||
|
||||
================================================
|
||||
PHASE 3 — XCUITEST IMPLEMENTATION
|
||||
================================================
|
||||
|
||||
Only after approval:
|
||||
- Write the test from A → E
|
||||
- Verify state at each step
|
||||
- Use explicit waits
|
||||
- Avoid fragile selectors
|
||||
- Extract helpers for navigation
|
||||
- Comment assumptions derived from screenshots
|
||||
|
||||
If anything is ambiguous, STOP and ask.
|
||||
```
|
||||
```
|
||||
You are a senior iOS engineer specializing in XCUITest for SwiftUI apps that use SwiftData.
|
||||
|
||||
Your task is to write production-grade, CI-stable XCUITests by analyzing:
|
||||
- A sequence of screenshots representing a user flow (A → B → C → D → E)
|
||||
- Any additional context I provide
|
||||
|
||||
You must follow a gated, multi-phase workflow.
|
||||
DO NOT write any test code until I explicitly approve.
|
||||
|
||||
====================================================
|
||||
GLOBAL RULES (NON-NEGOTIABLE)
|
||||
====================================================
|
||||
|
||||
- Screenshots are visual evidence, not implementation truth
|
||||
- Do NOT invent:
|
||||
- Accessibility identifiers
|
||||
- View names or file names
|
||||
- SwiftData models or relationships
|
||||
- Prefer asking questions over guessing
|
||||
- Treat SwiftData persistence as a source of flakiness unless constrained
|
||||
- Assume tests run on CI across multiple simulators and iOS versions
|
||||
|
||||
====================================================
|
||||
PHASE 1 — VISUAL FLOW ANALYSIS (MANDATORY)
|
||||
====================================================
|
||||
|
||||
From the screenshots, you must:
|
||||
|
||||
1. Infer and describe the user journey:
|
||||
- Entry point (first screen)
|
||||
- Navigation transitions between screens:
|
||||
- push (NavigationStack)
|
||||
- modal
|
||||
- sheet
|
||||
- tab switch
|
||||
- User actions that cause each transition
|
||||
|
||||
2. Produce a numbered flow:
|
||||
Step 1: Screen A → User Action → Expected Result
|
||||
Step 2: Screen B → User Action → Expected Result
|
||||
...
|
||||
Final Step: Screen E → Final Expected State
|
||||
|
||||
3. Identify what is being validated at each step:
|
||||
- Screen identity
|
||||
- Navigation success
|
||||
- Visible state change
|
||||
- Data presence or mutation
|
||||
|
||||
4. Explicitly list what CANNOT be known from screenshots:
|
||||
- Accessibility identifiers
|
||||
- SwiftUI view/file ownership
|
||||
- SwiftData schema details
|
||||
- Data source configuration
|
||||
|
||||
STOP after Phase 1 and wait for confirmation.
|
||||
|
||||
====================================================
|
||||
PHASE 2 — SWIFTDATA DISCOVERY (MANDATORY)
|
||||
====================================================
|
||||
|
||||
Before any test planning, you must:
|
||||
|
||||
1. Determine SwiftData involvement assumptions:
|
||||
- Which screens appear to depend on persisted data
|
||||
- Whether data is being created, edited, or merely displayed
|
||||
- Whether ordering, filtering, or relationships are visible
|
||||
|
||||
2. Identify SwiftData risks:
|
||||
- Empty vs non-empty states
|
||||
- Non-deterministic ordering
|
||||
- Schema migration crashes
|
||||
- Data leaking between tests
|
||||
- Async fetch timing affecting UI
|
||||
|
||||
3. Propose (do not assume) a test data strategy:
|
||||
- In-memory ModelContainer (preferred)
|
||||
- Explicit seeding via launch arguments
|
||||
- Destructive reset at test launch
|
||||
- Production container with isolation safeguards
|
||||
|
||||
4. Ask me clarifying questions about:
|
||||
- Whether persistence correctness is under test or just UI behavior
|
||||
- Whether destructive resets are allowed in tests
|
||||
- Whether test data may be injected or must use real flows
|
||||
|
||||
STOP after this phase and wait.
|
||||
|
||||
====================================================
|
||||
PHASE 3 — SELECTORS & ACCESSIBILITY CONTRACT
|
||||
====================================================
|
||||
|
||||
Before writing code, you must:
|
||||
|
||||
1. Request or propose accessibility identifiers for:
|
||||
- Screens
|
||||
- Interactive controls
|
||||
- Assertion targets
|
||||
|
||||
2. If proposing identifiers:
|
||||
- Clearly label them as PROPOSED
|
||||
- Group them by screen
|
||||
- Explain why each is needed for test stability
|
||||
|
||||
3. Identify selectors that would be fragile:
|
||||
- Static text labels
|
||||
- Localized strings
|
||||
- firstMatch or indexed access
|
||||
|
||||
STOP and wait for approval.
|
||||
|
||||
====================================================
|
||||
PHASE 4 — TEST PLAN (REQUIRED)
|
||||
====================================================
|
||||
|
||||
After clarification, produce a concise test plan containing:
|
||||
- Test name(s)
|
||||
- Given / When / Then for each test
|
||||
- Preconditions and seeded data
|
||||
- Assertions at each step (not just existence)
|
||||
- Setup and teardown strategy
|
||||
- Any helper abstractions you intend to create
|
||||
|
||||
WAIT for explicit approval before proceeding.
|
||||
|
||||
====================================================
|
||||
PHASE 5 — XCUITEST IMPLEMENTATION
|
||||
====================================================
|
||||
|
||||
Only after approval, write the XCUITest with these rules:
|
||||
|
||||
- Accessibility identifiers over labels
|
||||
- No element(boundBy:)
|
||||
- No firstMatch unless justified
|
||||
- Explicit waitForExistence timeouts
|
||||
- No sleep()
|
||||
- Assert state changes, not just visibility
|
||||
- Tests must be deterministic, isolated, and CI-safe
|
||||
- Extract helpers for navigation and setup
|
||||
- Comment assumptions inferred from screenshots
|
||||
|
||||
If any ambiguity remains, STOP and ask.
|
||||
|
||||
Begin Phase 1 once I provide the screenshots.
|
||||
```
|
||||
8
Scripts/.claude/settings.local.json
Normal file
8
Scripts/.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Skill(superpowers:brainstorming)",
|
||||
"Skill(superpowers:writing-plans)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ enum CKRecordType {
|
||||
static let stadiumAlias = "StadiumAlias"
|
||||
static let tripPoll = "TripPoll"
|
||||
static let pollVote = "PollVote"
|
||||
static let customItineraryItem = "CustomItineraryItem"
|
||||
}
|
||||
|
||||
// MARK: - CKTeam
|
||||
@@ -622,3 +623,74 @@ struct CKPollVote {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 sortOrderKey = "sortOrder"
|
||||
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.sortOrderKey] = item.sortOrder
|
||||
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
|
||||
let sortOrder = record[CKCustomItineraryItem.sortOrderKey] as? Int ?? 0
|
||||
|
||||
return CustomItineraryItem(
|
||||
id: itemId,
|
||||
tripId: tripId,
|
||||
category: category,
|
||||
title: title,
|
||||
anchorType: anchorType,
|
||||
anchorId: anchorId,
|
||||
anchorDay: anchorDay,
|
||||
sortOrder: sortOrder,
|
||||
createdAt: createdAt,
|
||||
modifiedAt: modifiedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
83
SportsTime/Core/Models/Domain/CustomItineraryItem.swift
Normal file
83
SportsTime/Core/Models/Domain/CustomItineraryItem.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// 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
|
||||
var sortOrder: Int // For ordering within same anchor position
|
||||
let createdAt: Date
|
||||
var modifiedAt: Date
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
tripId: UUID,
|
||||
category: ItemCategory,
|
||||
title: String,
|
||||
anchorType: AnchorType = .startOfDay,
|
||||
anchorId: String? = nil,
|
||||
anchorDay: Int,
|
||||
sortOrder: Int = 0,
|
||||
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.sortOrder = sortOrder
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,79 @@ final class TripVote {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User Preferences
|
||||
|
||||
@Model
|
||||
|
||||
48
SportsTime/Core/Services/AppDelegate.swift
Normal file
48
SportsTime/Core/Services/AppDelegate.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// AppDelegate.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Handles push notification registration and CloudKit subscription notifications
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import CloudKit
|
||||
import UserNotifications
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
// Register for remote notifications (required for CloudKit subscriptions)
|
||||
application.registerForRemoteNotifications()
|
||||
return true
|
||||
}
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
||||
) {
|
||||
print("📡 [Push] Registered for remote notifications")
|
||||
}
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFailToRegisterForRemoteNotificationsWithError error: Error
|
||||
) {
|
||||
print("📡 [Push] Failed to register: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
||||
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
|
||||
) {
|
||||
// Handle CloudKit subscription notification
|
||||
Task {
|
||||
await CustomItemSubscriptionService.shared.handleRemoteNotification(userInfo: userInfo)
|
||||
completionHandler(.newData)
|
||||
}
|
||||
}
|
||||
}
|
||||
204
SportsTime/Core/Services/CustomItemService.swift
Normal file
204
SportsTime/Core/Services/CustomItemService.swift
Normal file
@@ -0,0 +1,204 @@
|
||||
//
|
||||
// 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 {
|
||||
print("☁️ [CloudKit] Creating record for item: \(item.id)")
|
||||
let ckItem = CKCustomItineraryItem(item: item)
|
||||
|
||||
do {
|
||||
let savedRecord = try await publicDatabase.save(ckItem.record)
|
||||
print("☁️ [CloudKit] Record saved: \(savedRecord.recordID.recordName)")
|
||||
return item
|
||||
} catch let error as CKError {
|
||||
print("☁️ [CloudKit] CKError on create: \(error.code.rawValue) - \(error.localizedDescription)")
|
||||
throw mapCloudKitError(error)
|
||||
} catch {
|
||||
print("☁️ [CloudKit] Unknown error on create: \(error)")
|
||||
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.sortOrderKey] = item.sortOrder
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch update sortOrder for multiple items (for reordering)
|
||||
func updateSortOrders(_ items: [CustomItineraryItem]) async throws {
|
||||
guard !items.isEmpty else { return }
|
||||
print("☁️ [CloudKit] Batch updating sortOrder for \(items.count) items")
|
||||
|
||||
// Fetch all records
|
||||
let recordIDs = items.map { CKRecord.ID(recordName: $0.id.uuidString) }
|
||||
let fetchResults = try await publicDatabase.records(for: recordIDs)
|
||||
|
||||
// Update each record's sortOrder
|
||||
var recordsToSave: [CKRecord] = []
|
||||
let now = Date()
|
||||
|
||||
for item in items {
|
||||
let recordID = CKRecord.ID(recordName: item.id.uuidString)
|
||||
guard case .success(let record) = fetchResults[recordID] else { continue }
|
||||
|
||||
record[CKCustomItineraryItem.sortOrderKey] = item.sortOrder
|
||||
record[CKCustomItineraryItem.modifiedAtKey] = now
|
||||
recordsToSave.append(record)
|
||||
}
|
||||
|
||||
// Save all in one batch operation
|
||||
let modifyOp = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: nil)
|
||||
modifyOp.savePolicy = .changedKeys
|
||||
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
modifyOp.modifyRecordsResultBlock = { result in
|
||||
switch result {
|
||||
case .success:
|
||||
print("☁️ [CloudKit] Batch sortOrder update complete")
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
print("☁️ [CloudKit] Batch update failed: \(error)")
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
publicDatabase.add(modifyOp)
|
||||
}
|
||||
}
|
||||
|
||||
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] {
|
||||
print("☁️ [CloudKit] Fetching items for tripId: \(tripId.uuidString)")
|
||||
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)
|
||||
print("☁️ [CloudKit] Query returned \(results.count) records")
|
||||
|
||||
let items = results.compactMap { result -> CustomItineraryItem? in
|
||||
guard case .success(let record) = result.1 else {
|
||||
print("☁️ [CloudKit] Record fetch failed for: \(result.0.recordName)")
|
||||
return nil
|
||||
}
|
||||
let item = CKCustomItineraryItem(record: record).toItem()
|
||||
if item == nil {
|
||||
print("☁️ [CloudKit] Failed to parse record: \(record.recordID.recordName)")
|
||||
}
|
||||
return item
|
||||
}.sorted { ($0.anchorDay, $0.sortOrder) < ($1.anchorDay, $1.sortOrder) }
|
||||
|
||||
print("☁️ [CloudKit] Parsed \(items.count) valid items")
|
||||
return items
|
||||
} catch let error as CKError {
|
||||
print("☁️ [CloudKit] CKError on fetch: \(error.code.rawValue) - \(error.localizedDescription)")
|
||||
throw mapCloudKitError(error)
|
||||
} catch {
|
||||
print("☁️ [CloudKit] Unknown error on fetch: \(error)")
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
147
SportsTime/Core/Services/CustomItemSubscriptionService.swift
Normal file
147
SportsTime/Core/Services/CustomItemSubscriptionService.swift
Normal file
@@ -0,0 +1,147 @@
|
||||
//
|
||||
// CustomItemSubscriptionService.swift
|
||||
// SportsTime
|
||||
//
|
||||
// CloudKit subscription service for real-time custom item updates
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CloudKit
|
||||
import Combine
|
||||
|
||||
/// Manages CloudKit subscriptions for custom itinerary items on shared trips
|
||||
actor CustomItemSubscriptionService {
|
||||
static let shared = CustomItemSubscriptionService()
|
||||
|
||||
private let container: CKContainer
|
||||
private let publicDatabase: CKDatabase
|
||||
|
||||
/// Publisher that emits trip IDs when their custom items change
|
||||
private let changeSubject = PassthroughSubject<UUID, Never>()
|
||||
var changePublisher: AnyPublisher<UUID, Never> {
|
||||
changeSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/// Track which trip IDs have active subscriptions
|
||||
private var subscribedTripIds: Set<UUID> = []
|
||||
|
||||
private init() {
|
||||
self.container = CKContainer(identifier: "iCloud.com.sportstime.app")
|
||||
self.publicDatabase = container.publicCloudDatabase
|
||||
}
|
||||
|
||||
// MARK: - Subscription Management
|
||||
|
||||
/// Subscribe to changes for a specific trip's custom items
|
||||
func subscribeToTrip(_ tripId: UUID) async throws {
|
||||
guard !subscribedTripIds.contains(tripId) else {
|
||||
print("📡 [Subscription] Already subscribed to trip: \(tripId)")
|
||||
return
|
||||
}
|
||||
|
||||
let subscriptionID = "custom-items-\(tripId.uuidString)"
|
||||
|
||||
// Check if subscription already exists
|
||||
do {
|
||||
_ = try await publicDatabase.subscription(for: subscriptionID)
|
||||
subscribedTripIds.insert(tripId)
|
||||
print("📡 [Subscription] Found existing subscription for trip: \(tripId)")
|
||||
return
|
||||
} catch let error as CKError where error.code == .unknownItem {
|
||||
// Subscription doesn't exist, create it
|
||||
} catch {
|
||||
print("📡 [Subscription] Error checking subscription: \(error)")
|
||||
}
|
||||
|
||||
// Create subscription for this trip's custom items
|
||||
let predicate = NSPredicate(
|
||||
format: "%K == %@",
|
||||
CKCustomItineraryItem.tripIdKey,
|
||||
tripId.uuidString
|
||||
)
|
||||
|
||||
let subscription = CKQuerySubscription(
|
||||
recordType: CKRecordType.customItineraryItem,
|
||||
predicate: predicate,
|
||||
subscriptionID: subscriptionID,
|
||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
|
||||
)
|
||||
|
||||
// Configure notification
|
||||
let notificationInfo = CKSubscription.NotificationInfo()
|
||||
notificationInfo.shouldSendContentAvailable = true // Silent notification
|
||||
subscription.notificationInfo = notificationInfo
|
||||
|
||||
do {
|
||||
try await publicDatabase.save(subscription)
|
||||
subscribedTripIds.insert(tripId)
|
||||
print("📡 [Subscription] Created subscription for trip: \(tripId)")
|
||||
} catch let error as CKError {
|
||||
print("📡 [Subscription] Failed to create subscription: \(error.code.rawValue) - \(error.localizedDescription)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsubscribe from a specific trip's custom items
|
||||
func unsubscribeFromTrip(_ tripId: UUID) async throws {
|
||||
guard subscribedTripIds.contains(tripId) else { return }
|
||||
|
||||
let subscriptionID = "custom-items-\(tripId.uuidString)"
|
||||
|
||||
do {
|
||||
try await publicDatabase.deleteSubscription(withID: subscriptionID)
|
||||
subscribedTripIds.remove(tripId)
|
||||
print("📡 [Subscription] Removed subscription for trip: \(tripId)")
|
||||
} catch let error as CKError where error.code == .unknownItem {
|
||||
// Already deleted
|
||||
subscribedTripIds.remove(tripId)
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to all trips that the user has access to (via polls)
|
||||
func subscribeToSharedTrips(tripIds: [UUID]) async {
|
||||
for tripId in tripIds {
|
||||
do {
|
||||
try await subscribeToTrip(tripId)
|
||||
} catch {
|
||||
print("📡 [Subscription] Failed to subscribe to trip \(tripId): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Handling
|
||||
|
||||
/// Handle CloudKit notification - call this from AppDelegate/SceneDelegate
|
||||
func handleNotification(_ notification: CKNotification) {
|
||||
guard let queryNotification = notification as? CKQueryNotification,
|
||||
let subscriptionID = queryNotification.subscriptionID,
|
||||
subscriptionID.hasPrefix("custom-items-") else {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract trip ID from subscription ID
|
||||
let tripIdString = String(subscriptionID.dropFirst("custom-items-".count))
|
||||
guard let tripId = UUID(uuidString: tripIdString) else { return }
|
||||
|
||||
print("📡 [Subscription] Received change notification for trip: \(tripId)")
|
||||
changeSubject.send(tripId)
|
||||
}
|
||||
|
||||
/// Handle notification from userInfo dictionary (from push notification)
|
||||
nonisolated func handleRemoteNotification(userInfo: [AnyHashable: Any]) async {
|
||||
guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo) else {
|
||||
return
|
||||
}
|
||||
await handleNotification(notification)
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
/// Remove all subscriptions (call on sign out or cleanup)
|
||||
func removeAllSubscriptions() async {
|
||||
for tripId in subscribedTripIds {
|
||||
try? await unsubscribeFromTrip(tripId)
|
||||
}
|
||||
subscribedTripIds.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
98
SportsTime/Features/Trip/Views/CustomItemRow.swift
Normal file
98
SportsTime/Features/Trip/Views/CustomItemRow.swift
Normal file
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// 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 {
|
||||
// Opaque base + semi-transparent accent
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Theme.cardBackground(colorScheme))
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Theme.warmOrange.opacity(0.1))
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(Theme.warmOrange.opacity(0.3), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.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,8 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import MapKit
|
||||
import Combine
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct TripDetailView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@@ -14,6 +16,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 +33,14 @@ 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?
|
||||
@State private var dropTargetId: String? // Track which drop zone is being hovered
|
||||
|
||||
private let exportService = ExportService()
|
||||
private let dataProvider = AppDataProvider.shared
|
||||
|
||||
@@ -36,16 +49,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 +139,45 @@ 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()
|
||||
}
|
||||
.onChange(of: customItems) { _, _ in
|
||||
// Clear drag state after items update (move completed)
|
||||
draggedItem = nil
|
||||
dropTargetId = nil
|
||||
}
|
||||
.overlay {
|
||||
if isExporting {
|
||||
@@ -330,25 +393,311 @@ 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 {
|
||||
let sectionId = sectionIdentifier(for: section, at: index)
|
||||
let isDropTarget = dropTargetId == sectionId && draggedItem != nil
|
||||
|
||||
switch section {
|
||||
case .day(let dayNumber, let date, let gamesOnDay):
|
||||
DaySection(
|
||||
dayNumber: dayNumber,
|
||||
date: date,
|
||||
games: gamesOnDay
|
||||
)
|
||||
.staggeredAnimation(index: index)
|
||||
.overlay(alignment: .bottom) {
|
||||
if isDropTarget {
|
||||
DropTargetIndicator()
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
// Insert at beginning (sortOrder 0) when dropping on day card
|
||||
await moveItemToBeginning(item, toDay: dayNumber, anchorType: .afterGame, anchorId: lastGame.game.id)
|
||||
}
|
||||
return true
|
||||
} isTargeted: { targeted in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
dropTargetId = targeted ? sectionId : (dropTargetId == sectionId ? nil : dropTargetId)
|
||||
}
|
||||
}
|
||||
|
||||
case .travel(let segment):
|
||||
TravelSection(segment: segment)
|
||||
.staggeredAnimation(index: index)
|
||||
.overlay(alignment: .bottom) {
|
||||
if isDropTarget {
|
||||
DropTargetIndicator()
|
||||
}
|
||||
}
|
||||
.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)
|
||||
// Use stable identifier instead of UUID (UUIDs change on reload)
|
||||
let stableAnchorId = stableTravelAnchorId(segment)
|
||||
Task {
|
||||
// Insert at beginning when dropping on travel section
|
||||
await moveItemToBeginning(item, toDay: day, anchorType: .afterTravel, anchorId: stableAnchorId)
|
||||
}
|
||||
return true
|
||||
} isTargeted: { targeted in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
dropTargetId = targeted ? sectionId : (dropTargetId == sectionId ? nil : dropTargetId)
|
||||
}
|
||||
}
|
||||
|
||||
case .customItem(let item):
|
||||
let isDragging = draggedItem?.id == item.id
|
||||
CustomItemRow(
|
||||
item: item,
|
||||
onTap: { editingItem = item },
|
||||
onDelete: { Task { await deleteCustomItem(item) } }
|
||||
)
|
||||
.opacity(isDragging ? 0.4 : 1.0)
|
||||
.staggeredAnimation(index: index)
|
||||
.overlay(alignment: .top) {
|
||||
if isDropTarget && !isDragging {
|
||||
DropTargetIndicator()
|
||||
}
|
||||
}
|
||||
.draggable(item.id.uuidString) {
|
||||
// Drag preview
|
||||
CustomItemRow(
|
||||
item: item,
|
||||
onTap: {},
|
||||
onDelete: {}
|
||||
)
|
||||
.frame(width: 300)
|
||||
.opacity(0.9)
|
||||
.onAppear { draggedItem = item }
|
||||
}
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let itemIdString = items.first,
|
||||
let itemId = UUID(uuidString: itemIdString),
|
||||
let droppedItem = customItems.first(where: { $0.id == itemId }),
|
||||
droppedItem.id != item.id else { return false }
|
||||
Task {
|
||||
await moveItem(droppedItem, toDay: item.anchorDay, anchorType: item.anchorType, anchorId: item.anchorId, beforeItem: item)
|
||||
}
|
||||
draggedItem = nil
|
||||
dropTargetId = nil
|
||||
return true
|
||||
} isTargeted: { targeted in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
dropTargetId = targeted ? sectionId : (dropTargetId == sectionId ? nil : dropTargetId)
|
||||
}
|
||||
}
|
||||
|
||||
case .addButton(let day, let anchorType, let anchorId):
|
||||
VStack(spacing: 0) {
|
||||
if isDropTarget {
|
||||
DropTargetIndicator()
|
||||
}
|
||||
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 {
|
||||
// Insert at beginning when dropping on add button area
|
||||
await moveItemToBeginning(item, toDay: day, anchorType: anchorType, anchorId: anchorId)
|
||||
}
|
||||
draggedItem = nil
|
||||
dropTargetId = nil
|
||||
return true
|
||||
} isTargeted: { targeted in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
dropTargetId = targeted ? sectionId : (dropTargetId == sectionId ? nil : dropTargetId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a stable identifier for an itinerary section (for drop target tracking)
|
||||
private func sectionIdentifier(for section: ItinerarySection, at index: Int) -> String {
|
||||
switch section {
|
||||
case .day(let dayNumber, _, _):
|
||||
return "day-\(dayNumber)"
|
||||
case .travel(let segment):
|
||||
return "travel-\(segment.fromLocation.name)-\(segment.toLocation.name)"
|
||||
case .customItem(let item):
|
||||
return "item-\(item.id.uuidString)"
|
||||
case .addButton(let day, let anchorType, _):
|
||||
return "add-\(day)-\(anchorType.rawValue)"
|
||||
}
|
||||
}
|
||||
|
||||
private func findDayForTravelSegment(_ segment: TravelSegment) -> Int {
|
||||
// Find which day this travel segment belongs to by looking at sections
|
||||
// Travel appears BEFORE the arrival day, so look FORWARD to find arrival day
|
||||
for (index, section) in itinerarySections.enumerated() {
|
||||
if case .travel(let s) = section, s.id == segment.id {
|
||||
// Look forward to find the arrival day
|
||||
for i in (index + 1)..<itinerarySections.count {
|
||||
if case .day(let dayNumber, _, _) = itinerarySections[i] {
|
||||
return dayNumber
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
/// Create a stable anchor ID for a travel segment (UUIDs regenerate on reload)
|
||||
private func stableTravelAnchorId(_ segment: TravelSegment) -> String {
|
||||
let from = segment.fromLocation.name.lowercased().trimmingCharacters(in: .whitespaces)
|
||||
let to = segment.toLocation.name.lowercased().trimmingCharacters(in: .whitespaces)
|
||||
return "travel:\(from)->\(to)"
|
||||
}
|
||||
|
||||
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
|
||||
updated.modifiedAt = Date()
|
||||
|
||||
// Calculate sortOrder
|
||||
let itemsAtSameAnchor = customItems.filter {
|
||||
$0.anchorDay == day &&
|
||||
$0.anchorType == anchorType &&
|
||||
$0.anchorId == anchorId &&
|
||||
$0.id != item.id
|
||||
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||
|
||||
var itemsToSync: [CustomItineraryItem] = []
|
||||
|
||||
print("📍 [Move] itemsAtSameAnchor: \(itemsAtSameAnchor.map { "\($0.title) (id: \($0.id.uuidString.prefix(8)))" })")
|
||||
if let beforeItem = beforeItem {
|
||||
print("📍 [Move] beforeItem: \(beforeItem.title) (id: \(beforeItem.id.uuidString.prefix(8)))")
|
||||
}
|
||||
|
||||
if let beforeItem = beforeItem,
|
||||
let beforeIndex = itemsAtSameAnchor.firstIndex(where: { $0.id == beforeItem.id }) {
|
||||
updated.sortOrder = beforeIndex
|
||||
print("📍 [Move] Setting \(item.title) sortOrder to \(beforeIndex) (before \(beforeItem.title))")
|
||||
|
||||
// Shift other items and track them for syncing
|
||||
for i in beforeIndex..<itemsAtSameAnchor.count {
|
||||
if var shiftItem = customItems.first(where: { $0.id == itemsAtSameAnchor[i].id }) {
|
||||
shiftItem.sortOrder = i + 1
|
||||
shiftItem.modifiedAt = Date()
|
||||
print("📍 [Move] Shifting \(shiftItem.title) sortOrder to \(i + 1)")
|
||||
if let idx = customItems.firstIndex(where: { $0.id == shiftItem.id }) {
|
||||
customItems[idx] = shiftItem
|
||||
}
|
||||
itemsToSync.append(shiftItem)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updated.sortOrder = itemsAtSameAnchor.count
|
||||
print("📍 [Move] Setting \(item.title) sortOrder to \(itemsAtSameAnchor.count) (end of list)")
|
||||
}
|
||||
|
||||
// Update local state
|
||||
if let idx = customItems.firstIndex(where: { $0.id == item.id }) {
|
||||
customItems[idx] = updated
|
||||
}
|
||||
|
||||
// Sync moved item and all shifted items to CloudKit
|
||||
do {
|
||||
_ = try await CustomItemService.shared.updateItem(updated)
|
||||
print("✅ [Move] Synced \(updated.title) to day \(day), anchor: \(anchorType.rawValue), sortOrder: \(updated.sortOrder)")
|
||||
|
||||
// Also sync shifted items
|
||||
for shiftItem in itemsToSync {
|
||||
_ = try await CustomItemService.shared.updateItem(shiftItem)
|
||||
print("✅ [Move] Synced shifted item \(shiftItem.title) with sortOrder: \(shiftItem.sortOrder)")
|
||||
}
|
||||
} catch {
|
||||
print("❌ [Move] Failed to sync: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Move item to the beginning of an anchor position (sortOrder 0), shifting existing items down
|
||||
private func moveItemToBeginning(_ item: CustomItineraryItem, toDay day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?) async {
|
||||
var updated = item
|
||||
updated.anchorDay = day
|
||||
updated.anchorType = anchorType
|
||||
updated.anchorId = anchorId
|
||||
updated.sortOrder = 0 // Insert at beginning
|
||||
updated.modifiedAt = Date()
|
||||
|
||||
// Get existing items at this anchor position (excluding the moved item)
|
||||
let existingItems = customItems.filter {
|
||||
$0.anchorDay == day &&
|
||||
$0.anchorType == anchorType &&
|
||||
$0.anchorId == anchorId &&
|
||||
$0.id != item.id
|
||||
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||
|
||||
print("📍 [MoveToBeginning] Moving \(item.title) to beginning of day \(day), anchor: \(anchorType.rawValue)")
|
||||
print("📍 [MoveToBeginning] Existing items to shift: \(existingItems.map { "\($0.title) (order: \($0.sortOrder))" })")
|
||||
|
||||
// Shift all existing items down by 1
|
||||
var itemsToSync: [CustomItineraryItem] = []
|
||||
for (index, existingItem) in existingItems.enumerated() {
|
||||
if var shiftItem = customItems.first(where: { $0.id == existingItem.id }) {
|
||||
shiftItem.sortOrder = index + 1 // Shift down
|
||||
shiftItem.modifiedAt = Date()
|
||||
print("📍 [MoveToBeginning] Shifting \(shiftItem.title) sortOrder to \(index + 1)")
|
||||
if let idx = customItems.firstIndex(where: { $0.id == shiftItem.id }) {
|
||||
customItems[idx] = shiftItem
|
||||
}
|
||||
itemsToSync.append(shiftItem)
|
||||
}
|
||||
}
|
||||
|
||||
// Update local state for the moved item
|
||||
if let idx = customItems.firstIndex(where: { $0.id == item.id }) {
|
||||
customItems[idx] = updated
|
||||
}
|
||||
|
||||
// Sync to CloudKit
|
||||
do {
|
||||
_ = try await CustomItemService.shared.updateItem(updated)
|
||||
print("✅ [MoveToBeginning] Synced \(updated.title) with sortOrder: 0")
|
||||
|
||||
for shiftItem in itemsToSync {
|
||||
_ = try await CustomItemService.shared.updateItem(shiftItem)
|
||||
print("✅ [MoveToBeginning] Synced shifted item \(shiftItem.title) with sortOrder: \(shiftItem.sortOrder)")
|
||||
}
|
||||
} catch {
|
||||
print("❌ [MoveToBeginning] 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 +730,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 +743,57 @@ struct TripDetailView: View {
|
||||
if !prevCity.isEmpty && !currentCity.isEmpty && prevCity != currentCity {
|
||||
if let travelSegment = findTravelSegment(from: prevCity, to: currentCity) {
|
||||
sections.append(.travel(travelSegment))
|
||||
|
||||
if allowCustomItems {
|
||||
// Use stable anchor ID for travel segments
|
||||
let stableId = stableTravelAnchorId(travelSegment)
|
||||
|
||||
// Add button after travel
|
||||
sections.append(.addButton(day: section.dayNumber, anchorType: .afterTravel, anchorId: stableId))
|
||||
|
||||
// Custom items after this travel (sorted by sortOrder)
|
||||
let itemsAfterTravel = customItems.filter {
|
||||
$0.anchorDay == section.dayNumber &&
|
||||
$0.anchorType == .afterTravel &&
|
||||
$0.anchorId == stableId
|
||||
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||
for item in itemsAfterTravel {
|
||||
sections.append(.customItem(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if allowCustomItems {
|
||||
// Custom items at start of day (sorted by sortOrder)
|
||||
let itemsAtStart = customItems.filter {
|
||||
$0.anchorDay == section.dayNumber && $0.anchorType == .startOfDay
|
||||
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||
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 (sorted by sortOrder)
|
||||
let itemsAfterGame = customItems.filter {
|
||||
$0.anchorDay == section.dayNumber &&
|
||||
$0.anchorType == .afterGame &&
|
||||
$0.anchorId == lastGame.game.id
|
||||
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||
for item in itemsAfterGame {
|
||||
sections.append(.customItem(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
@@ -643,6 +1037,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), sortOrder: \(item.sortOrder))")
|
||||
}
|
||||
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 +1125,65 @@ 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)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drop Target Indicator
|
||||
|
||||
private struct DropTargetIndicator: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(Theme.warmOrange)
|
||||
.frame(width: 8, height: 8)
|
||||
Rectangle()
|
||||
.fill(Theme.warmOrange)
|
||||
.frame(height: 2)
|
||||
Circle()
|
||||
.fill(Theme.warmOrange)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
.padding(.vertical, 4)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.8)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Day Section
|
||||
@@ -783,14 +1317,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
|
||||
@@ -869,18 +1397,18 @@ struct TravelSection: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Theme.cardBackground(colorScheme).opacity(0.7))
|
||||
.background {
|
||||
// Opaque base + semi-transparent accent (prevents line showing through)
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.fill(Theme.cardBackground(colorScheme))
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.fill(Theme.routeGold.opacity(0.05))
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.overlay {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ import BackgroundTasks
|
||||
|
||||
@main
|
||||
struct SportsTimeApp: App {
|
||||
/// App delegate for handling push notifications
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
|
||||
/// Task that listens for StoreKit transaction updates
|
||||
private var transactionListener: Task<Void, Never>?
|
||||
|
||||
|
||||
57
SportsTimeTests/Domain/CustomItineraryItemTests.swift
Normal file
57
SportsTimeTests/Domain/CustomItineraryItemTests.swift
Normal file
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
31
TO-DOS.md
31
TO-DOS.md
@@ -1,41 +1,10 @@
|
||||
```
|
||||
Read TO-DOS.md in full.
|
||||
Summarize:
|
||||
- Goal
|
||||
- Current Phase
|
||||
- Active Tasks
|
||||
Do not write code until this summary is complete.
|
||||
|
||||
/superpowers:brainstorm todo X <-- will create a design doc in the docs/plan
|
||||
|
||||
/superpowers:write-plan for the design created
|
||||
|
||||
/superpowers:subagent-driven-development
|
||||
|
||||
read docs/TEST_PLAN.md in full
|
||||
|
||||
Summarize:
|
||||
- Goal
|
||||
- Current Phase
|
||||
- Active Tasks
|
||||
Do not write code until this summary is complete.
|
||||
```
|
||||
|
||||
question: do we need sync schedules anymore in settings
|
||||
|
||||
// new dev
|
||||
- Notification reminders - "Your trip starts in 3 days"
|
||||
|
||||
// enhancements
|
||||
- Dark/light mode toggle along with system
|
||||
- Stadium notes - let users add personal notes/tips to stadiums they've visited
|
||||
sharing needs to completely overhauled. should be able to share a trip summary, achievements, progress. social media first
|
||||
- Achievements share says 2,026. Should be an option when share is hit of year or all time
|
||||
- if viewing a single sport share should share details regarding that sport only
|
||||
- need achievements for every supported league (how does this work with adding new sports in backed, might have to push with app update so if not achievements exist for that sport don’t show it as an option)
|
||||
- Share needs more styling, similar to onboarding
|
||||
- Ready to plan your trip has a summary, missing details should be in red
|
||||
|
||||
|
||||
// bugs
|
||||
- fucking game show at 7 am ... the fuck?
|
||||
|
||||
195
docs/plans/2026-01-15-custom-itinerary-items-design.md
Normal file
195
docs/plans/2026-01-15-custom-itinerary-items-design.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Custom Itinerary Items Design
|
||||
|
||||
**Date:** 2026-01-15
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Allow users to add custom items (restaurants, hotels, activities, notes) to saved itineraries. Items can be repositioned anywhere among the system-generated content (games, travel segments) but system items remain fixed.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Categorized items with visual icons (Restaurant, Hotel, Activity, Note)
|
||||
- Inline "Add" buttons between each itinerary section
|
||||
- Always-visible drag handles on custom items (system items have no handles)
|
||||
- CloudKit sync across devices
|
||||
- Swipe to delete, tap to edit
|
||||
|
||||
## Data Model
|
||||
|
||||
```swift
|
||||
struct CustomItineraryItem: Identifiable, Codable, Hashable {
|
||||
let id: UUID
|
||||
let tripId: UUID
|
||||
var category: ItemCategory
|
||||
var title: String
|
||||
var anchorType: AnchorType // What this item is positioned after
|
||||
var anchorId: String? // ID of game or travel segment (nil = start of day)
|
||||
var anchorDay: Int // Day number (1-based)
|
||||
let createdAt: Date
|
||||
var modifiedAt: Date
|
||||
|
||||
enum ItemCategory: String, Codable, CaseIterable {
|
||||
case restaurant, hotel, activity, 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AnchorType: String, Codable {
|
||||
case startOfDay // Before any games/travel on this day
|
||||
case afterGame // After a specific game
|
||||
case afterTravel // After a specific travel segment
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why anchor-based positioning?** If a game gets rescheduled or removed, index-based positions would break. Anchoring to a specific game/travel ID is more stable. If the anchor is deleted, the item falls back to `startOfDay`.
|
||||
|
||||
## UI Components
|
||||
|
||||
### Inline "Add Item" Buttons
|
||||
|
||||
Small, subtle `+ Add` buttons appear between each itinerary section:
|
||||
|
||||
```
|
||||
Day 1 - Arlington
|
||||
├─ 🎾 SFG @ TEX (12:05 AM)
|
||||
├─ [+ Add]
|
||||
├─ 🚗 Drive to Milwaukee
|
||||
├─ [+ Add]
|
||||
└─ 🎾 PIT @ MIL (7:10 PM)
|
||||
[+ Add]
|
||||
|
||||
Day 2 - Chicago
|
||||
├─ [+ Add] ← Start of day
|
||||
├─ 🎾 LAD @ CHC (1:20 PM)
|
||||
[+ Add]
|
||||
```
|
||||
|
||||
### Add Item Sheet
|
||||
|
||||
A simple modal with:
|
||||
- Category picker (4 icons in a horizontal row, tap to select)
|
||||
- Text field for title
|
||||
- "Add" button
|
||||
|
||||
### Custom Item Row
|
||||
|
||||
Displays in the itinerary with:
|
||||
- Category icon on the left
|
||||
- Title text
|
||||
- Drag handle (≡) on the right - always visible
|
||||
- Swipe left reveals red "Delete" action
|
||||
- Tap opens edit sheet (same as add, pre-filled)
|
||||
- Subtle warm background tint for visual distinction
|
||||
|
||||
## Reordering Logic
|
||||
|
||||
Games and travel segments are fixed. Only custom items can be dragged.
|
||||
|
||||
### How it works
|
||||
|
||||
1. The itinerary renders as a flat list of mixed item types
|
||||
2. When a custom item is dragged, it can only be dropped into valid "slots" between system items
|
||||
3. On drop, the item's anchor is updated to reference what it's now positioned after
|
||||
|
||||
### Implementation approach
|
||||
|
||||
<!--
|
||||
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)
|
||||
-->
|
||||
|
||||
Use custom drag-and-drop with `draggable()` and `dropDestination()` modifiers rather than `.onMove`. This gives precise control over what can be dragged and where it can land.
|
||||
|
||||
```swift
|
||||
ForEach(itinerarySections) { section in
|
||||
switch section {
|
||||
case .game, .travel:
|
||||
// Render normally, no drag handle
|
||||
// But IS a drop destination
|
||||
case .customItem:
|
||||
// Render with drag handle
|
||||
// draggable() + dropDestination()
|
||||
case .addButton:
|
||||
// Inline add button (also a drop destination)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CloudKit Sync
|
||||
|
||||
### New record type: `CustomItineraryItem`
|
||||
|
||||
Fields:
|
||||
- `itemId` (String) - UUID
|
||||
- `tripId` (String) - References the SavedTrip
|
||||
- `category` (String) - restaurant/hotel/activity/note
|
||||
- `title` (String)
|
||||
- `anchorType` (String)
|
||||
- `anchorId` (String, optional)
|
||||
- `anchorDay` (Int)
|
||||
- `createdAt` (Date)
|
||||
- `modifiedAt` (Date)
|
||||
|
||||
### Sync approach
|
||||
|
||||
Follows the same pattern as `PollVote`:
|
||||
- `CKCustomItineraryItem` wrapper struct in `CKModels.swift`
|
||||
- CRUD methods in a new `CustomItemService` actor
|
||||
- Items are fetched when loading a saved trip
|
||||
- Changes sync immediately on add/edit/delete/reorder
|
||||
|
||||
### Conflict resolution
|
||||
|
||||
Last-write-wins based on `modifiedAt` for both content edits and position changes.
|
||||
|
||||
### Offline handling
|
||||
|
||||
Custom items are also stored locally in SwiftData alongside `SavedTrip`. When offline, changes queue locally and sync when connection restores.
|
||||
|
||||
## Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Core/Models/Domain/CustomItineraryItem.swift` | Domain model |
|
||||
| `Core/Models/CloudKit/CKCustomItineraryItem.swift` | CloudKit wrapper |
|
||||
| `Core/Services/CustomItemService.swift` | CloudKit CRUD operations |
|
||||
| `Features/Trip/Views/AddItemSheet.swift` | Modal for adding/editing items |
|
||||
| `Features/Trip/Views/CustomItemRow.swift` | Row component with drag handle |
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `TripDetailView.swift` | Add inline buttons, render custom items, handle drag/drop |
|
||||
| `SavedTrip.swift` | Add relationship to custom items (local cache) |
|
||||
| `CanonicalSyncService.swift` | Sync custom items with CloudKit |
|
||||
|
||||
## Out of Scope (YAGNI)
|
||||
|
||||
- Time/duration fields on custom items
|
||||
- Location/address fields
|
||||
- Photos/attachments
|
||||
- Sharing custom items in polls
|
||||
- Notifications/reminders for custom items
|
||||
|
||||
These can be added in future iterations if needed.
|
||||
1291
docs/plans/2026-01-15-custom-itinerary-items-implementation.md
Normal file
1291
docs/plans/2026-01-15-custom-itinerary-items-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user