feat(itinerary): add custom itinerary items with drag-to-reorder
- Add CustomItineraryItem domain model with sortOrder for ordering - Add CKCustomItineraryItem CloudKit wrapper for persistence - Create CustomItemService for CRUD operations - Create CustomItemSubscriptionService for real-time sync - Add AppDelegate for push notification handling - Add AddItemSheet for creating/editing items - Add CustomItemRow with drag handle - Update TripDetailView with continuous vertical timeline - Enable drag-to-reorder using .draggable/.dropDestination - Add inline "Add" buttons after games and travel segments Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ enum CKRecordType {
|
|||||||
static let stadiumAlias = "StadiumAlias"
|
static let stadiumAlias = "StadiumAlias"
|
||||||
static let tripPoll = "TripPoll"
|
static let tripPoll = "TripPoll"
|
||||||
static let pollVote = "PollVote"
|
static let pollVote = "PollVote"
|
||||||
|
static let customItineraryItem = "CustomItineraryItem"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CKTeam
|
// 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
|
// MARK: - User Preferences
|
||||||
|
|
||||||
@Model
|
@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 {
|
var body: some View {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
TripDetailView(trip: trip)
|
TripDetailView(trip: trip, games: savedTrip.games, allowCustomItems: true)
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: Theme.Spacing.md) {
|
HStack(spacing: Theme.Spacing.md) {
|
||||||
// Route preview icon
|
// Route preview icon
|
||||||
@@ -555,7 +555,7 @@ struct SavedTripsListView: View {
|
|||||||
ForEach(Array(sortedTrips.enumerated()), id: \.element.id) { index, savedTrip in
|
ForEach(Array(sortedTrips.enumerated()), id: \.element.id) { index, savedTrip in
|
||||||
if let trip = savedTrip.trip {
|
if let trip = savedTrip.trip {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
TripDetailView(trip: trip)
|
TripDetailView(trip: trip, games: savedTrip.games, allowCustomItems: true)
|
||||||
} label: {
|
} label: {
|
||||||
SavedTripListRow(trip: trip)
|
SavedTripListRow(trip: trip)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ struct PollDetailView: View {
|
|||||||
}
|
}
|
||||||
.sheet(item: $selectedTrip) { trip in
|
.sheet(item: $selectedTrip) { trip in
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
TripDetailView(trip: trip)
|
TripDetailView(trip: trip, allowCustomItems: true)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
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 }
|
||||||
|
}
|
||||||
89
SportsTime/Features/Trip/Views/CustomItemRow.swift
Normal file
89
SportsTime/Features/Trip/Views/CustomItemRow.swift
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
//
|
||||||
|
// 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(Theme.warmOrange.opacity(0.08))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
.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,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import MapKit
|
import MapKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
struct TripDetailView: View {
|
struct TripDetailView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@@ -14,6 +15,9 @@ struct TripDetailView: View {
|
|||||||
let trip: Trip
|
let trip: Trip
|
||||||
private let providedGames: [String: RichGame]?
|
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]
|
@Query private var savedTrips: [SavedTrip]
|
||||||
@State private var showProPaywall = false
|
@State private var showProPaywall = false
|
||||||
@State private var selectedDay: ItineraryDay?
|
@State private var selectedDay: ItineraryDay?
|
||||||
@@ -28,6 +32,13 @@ struct TripDetailView: View {
|
|||||||
@State private var loadedGames: [String: RichGame] = [:]
|
@State private var loadedGames: [String: RichGame] = [:]
|
||||||
@State private var isLoadingGames = false
|
@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?
|
||||||
|
|
||||||
private let exportService = ExportService()
|
private let exportService = ExportService()
|
||||||
private let dataProvider = AppDataProvider.shared
|
private let dataProvider = AppDataProvider.shared
|
||||||
|
|
||||||
@@ -36,16 +47,32 @@ struct TripDetailView: View {
|
|||||||
providedGames ?? loadedGames
|
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]) {
|
init(trip: Trip, games: [String: RichGame]) {
|
||||||
self.trip = trip
|
self.trip = trip
|
||||||
self.providedGames = games
|
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) {
|
init(trip: Trip) {
|
||||||
self.trip = trip
|
self.trip = trip
|
||||||
self.providedGames = nil
|
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 {
|
var body: some View {
|
||||||
@@ -110,11 +137,40 @@ struct TripDetailView: View {
|
|||||||
.sheet(isPresented: $showProPaywall) {
|
.sheet(isPresented: $showProPaywall) {
|
||||||
PaywallView()
|
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 {
|
.onAppear {
|
||||||
checkIfSaved()
|
checkIfSaved()
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await loadGamesIfNeeded()
|
await loadGamesIfNeeded()
|
||||||
|
if allowCustomItems {
|
||||||
|
await loadCustomItems()
|
||||||
|
await setupSubscription()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
subscriptionCancellable?.cancel()
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
if isExporting {
|
if isExporting {
|
||||||
@@ -330,25 +386,165 @@ struct TripDetailView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ForEach(Array(itinerarySections.enumerated()), id: \.offset) { index, section in
|
// ZStack with continuous vertical line behind all content
|
||||||
switch section {
|
ZStack(alignment: .top) {
|
||||||
case .day(let dayNumber, let date, let gamesOnDay):
|
// Continuous vertical line down the center
|
||||||
DaySection(
|
Rectangle()
|
||||||
dayNumber: dayNumber,
|
.fill(Theme.routeGold.opacity(0.4))
|
||||||
date: date,
|
.frame(width: 2)
|
||||||
games: gamesOnDay
|
.frame(maxHeight: .infinity)
|
||||||
)
|
|
||||||
.staggeredAnimation(index: index)
|
// Itinerary content
|
||||||
case .travel(let segment):
|
VStack(spacing: Theme.Spacing.md) {
|
||||||
TravelSection(segment: segment)
|
ForEach(Array(itinerarySections.enumerated()), id: \.offset) { index, section in
|
||||||
.staggeredAnimation(index: index)
|
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 {
|
||||||
|
switch section {
|
||||||
|
case .day(let dayNumber, let date, let gamesOnDay):
|
||||||
|
DaySection(
|
||||||
|
dayNumber: dayNumber,
|
||||||
|
date: date,
|
||||||
|
games: gamesOnDay
|
||||||
|
)
|
||||||
|
.staggeredAnimation(index: index)
|
||||||
|
.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 {
|
||||||
|
await moveItem(item, toDay: dayNumber, anchorType: .afterGame, anchorId: lastGame.game.id)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
case .travel(let segment):
|
||||||
|
TravelSection(segment: segment)
|
||||||
|
.staggeredAnimation(index: index)
|
||||||
|
.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)
|
||||||
|
Task {
|
||||||
|
await moveItem(item, toDay: day, anchorType: .afterTravel, anchorId: segment.id.uuidString)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
case .customItem(let item):
|
||||||
|
CustomItemRow(
|
||||||
|
item: item,
|
||||||
|
onTap: { editingItem = item },
|
||||||
|
onDelete: { Task { await deleteCustomItem(item) } }
|
||||||
|
)
|
||||||
|
.staggeredAnimation(index: index)
|
||||||
|
.draggable(item.id.uuidString) {
|
||||||
|
// Drag preview
|
||||||
|
CustomItemRow(
|
||||||
|
item: item,
|
||||||
|
onTap: {},
|
||||||
|
onDelete: {}
|
||||||
|
)
|
||||||
|
.frame(width: 300)
|
||||||
|
.opacity(0.8)
|
||||||
|
}
|
||||||
|
.dropDestination(for: String.self) { items, _ in
|
||||||
|
guard let itemIdString = items.first,
|
||||||
|
let itemId = UUID(uuidString: itemIdString),
|
||||||
|
let draggedItem = customItems.first(where: { $0.id == itemId }),
|
||||||
|
draggedItem.id != item.id else { return false }
|
||||||
|
Task {
|
||||||
|
await moveItem(draggedItem, toDay: item.anchorDay, anchorType: item.anchorType, anchorId: item.anchorId, beforeItem: item)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
case .addButton(let day, let anchorType, let anchorId):
|
||||||
|
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 {
|
||||||
|
await moveItem(item, toDay: day, anchorType: anchorType, anchorId: anchorId)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findDayForTravelSegment(_ segment: TravelSegment) -> Int {
|
||||||
|
// Find which day this travel segment belongs to by looking at sections
|
||||||
|
for (index, section) in itinerarySections.enumerated() {
|
||||||
|
if case .travel(let s) = section, s.id == segment.id {
|
||||||
|
// Look backwards to find the day
|
||||||
|
for i in stride(from: index - 1, through: 0, by: -1) {
|
||||||
|
if case .day(let dayNumber, _, _) = itinerarySections[i] {
|
||||||
|
return dayNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// Calculate sortOrder
|
||||||
|
let itemsAtSameAnchor = customItems.filter {
|
||||||
|
$0.anchorDay == day &&
|
||||||
|
$0.anchorType == anchorType &&
|
||||||
|
$0.anchorId == anchorId &&
|
||||||
|
$0.id != item.id
|
||||||
|
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||||
|
|
||||||
|
if let beforeItem = beforeItem,
|
||||||
|
let beforeIndex = itemsAtSameAnchor.firstIndex(where: { $0.id == beforeItem.id }) {
|
||||||
|
updated.sortOrder = beforeIndex
|
||||||
|
// Shift other items
|
||||||
|
for i in beforeIndex..<itemsAtSameAnchor.count {
|
||||||
|
if var shiftItem = customItems.first(where: { $0.id == itemsAtSameAnchor[i].id }) {
|
||||||
|
shiftItem.sortOrder = i + 1
|
||||||
|
if let idx = customItems.firstIndex(where: { $0.id == shiftItem.id }) {
|
||||||
|
customItems[idx] = shiftItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updated.sortOrder = itemsAtSameAnchor.count
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
if let idx = customItems.firstIndex(where: { $0.id == item.id }) {
|
||||||
|
customItems[idx] = updated
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync to CloudKit
|
||||||
|
do {
|
||||||
|
_ = try await CustomItemService.shared.updateItem(updated)
|
||||||
|
print("✅ [Move] Moved item to day \(day), anchor: \(anchorType.rawValue)")
|
||||||
|
} catch {
|
||||||
|
print("❌ [Move] Failed to sync: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build itinerary sections: group by day AND city, with travel, custom items, and add buttons
|
||||||
private var itinerarySections: [ItinerarySection] {
|
private var itinerarySections: [ItinerarySection] {
|
||||||
var sections: [ItinerarySection] = []
|
var sections: [ItinerarySection] = []
|
||||||
|
|
||||||
@@ -381,7 +577,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() {
|
for (index, section) in dayCitySections.enumerated() {
|
||||||
|
|
||||||
// Check if we need travel BEFORE this section (coming from different city)
|
// Check if we need travel BEFORE this section (coming from different city)
|
||||||
@@ -394,12 +590,54 @@ struct TripDetailView: View {
|
|||||||
if !prevCity.isEmpty && !currentCity.isEmpty && prevCity != currentCity {
|
if !prevCity.isEmpty && !currentCity.isEmpty && prevCity != currentCity {
|
||||||
if let travelSegment = findTravelSegment(from: prevCity, to: currentCity) {
|
if let travelSegment = findTravelSegment(from: prevCity, to: currentCity) {
|
||||||
sections.append(.travel(travelSegment))
|
sections.append(.travel(travelSegment))
|
||||||
|
|
||||||
|
if allowCustomItems {
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if allowCustomItems {
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add the day section
|
// Add the day section
|
||||||
sections.append(.day(dayNumber: section.dayNumber, date: section.date, games: section.games))
|
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
|
||||||
|
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
|
return sections
|
||||||
@@ -643,6 +881,87 @@ struct TripDetailView: View {
|
|||||||
isSaved = true
|
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))")
|
||||||
|
}
|
||||||
|
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
|
// MARK: - Itinerary Section
|
||||||
@@ -650,6 +969,43 @@ struct TripDetailView: View {
|
|||||||
enum ItinerarySection {
|
enum ItinerarySection {
|
||||||
case day(dayNumber: Int, date: Date, games: [RichGame])
|
case day(dayNumber: Int, date: Date, games: [RichGame])
|
||||||
case travel(TravelSegment)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Day Section
|
// MARK: - Day Section
|
||||||
@@ -783,14 +1139,8 @@ struct TravelSection: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
// Travel card
|
||||||
VStack(spacing: 0) {
|
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
|
// Main travel info
|
||||||
HStack(spacing: Theme.Spacing.md) {
|
HStack(spacing: Theme.Spacing.md) {
|
||||||
// Icon
|
// Icon
|
||||||
@@ -875,12 +1225,6 @@ struct TravelSection: View {
|
|||||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||||
.strokeBorder(Theme.routeGold.opacity(0.3), lineWidth: 1)
|
.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
|
@main
|
||||||
struct SportsTimeApp: App {
|
struct SportsTimeApp: App {
|
||||||
|
/// App delegate for handling push notifications
|
||||||
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
|
|
||||||
/// Task that listens for StoreKit transaction updates
|
/// Task that listens for StoreKit transaction updates
|
||||||
private var transactionListener: Task<Void, Never>?
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user