From ea5c2c7d5457ac75b8661e18f53dcf1c43e03829 Mon Sep 17 00:00:00 2001 From: Chris Ballinger Date: Sun, 12 Apr 2026 18:10:06 -0400 Subject: [PATCH] Add persistent thumbnail color cache with fully-inflated ListRow pattern Replaces the dual-observation list architecture (items + separate favorites) with a single GRDB read transaction that returns ListRow containing the object, full ObjectMetadata, and cached ThumbnailColors. This eliminates redundant queries and provides instant color/metadata rendering. Key changes: - New thumbnail_colors GRDB table with Codable ThumbnailColors model - New ListRow generic wrapper (object + metadata + colors) - Single annotated observation replaces dual items+favorites observations - DisplayableObject.supportsColorTheming gates list row colors (Art/MV only) - Detail views receive pre-loaded data via DetailPageItem pass-through - ColorPrefetcher background batch extraction at app launch - Thumbnail downloaders validate corrupt files and return awaitable Tasks Co-Authored-By: Claude Opus 4.6 (1M context) --- ...-04-12-persistent-color-cache-list-rows.md | 545 ++++++++++++++++++ .../Sources/PlayaDB/Models/ListRow.swift | 18 + .../PlayaDB/Models/ThumbnailColors.swift | 78 +++ .../PlayaDB/Sources/PlayaDB/PlayaDB.swift | 40 +- .../PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift | 110 +++- iBurn/ColorPrefetcher.swift | 71 +++ iBurn/DependencyContainer.swift | 11 +- .../DetailViewControllerFactory.swift | 13 +- iBurn/Detail/Models/DetailSubject.swift | 19 + iBurn/Detail/ViewModels/DetailViewModel.swift | 39 +- iBurn/DetailPagingDataSource.swift | 36 +- iBurn/ListView/ArtDataProvider.swift | 12 +- iBurn/ListView/ArtListHostingController.swift | 8 +- iBurn/ListView/ArtListView.swift | 30 +- iBurn/ListView/CampDataProvider.swift | 10 +- .../ListView/CampListHostingController.swift | 8 +- iBurn/ListView/CampListView.swift | 21 +- iBurn/ListView/DisplayableObject.swift | 13 +- iBurn/ListView/EventDataProvider.swift | 10 +- .../ListView/EventListHostingController.swift | 8 +- iBurn/ListView/EventListView.swift | 23 +- iBurn/ListView/EventListViewModel.swift | 93 +-- iBurn/ListView/FavoriteItem.swift | 69 ++- .../FavoritesListHostingController.swift | 18 +- iBurn/ListView/FavoritesView.swift | 38 +- iBurn/ListView/FavoritesViewModel.swift | 68 +-- .../ListView/MutantVehicleDataProvider.swift | 10 +- .../MutantVehicleListHostingController.swift | 8 +- iBurn/ListView/MutantVehicleListView.swift | 13 +- iBurn/ListView/NearbyItem.swift | 58 +- .../NearbyListHostingController.swift | 6 +- iBurn/ListView/NearbyView.swift | 29 +- iBurn/ListView/NearbyViewModel.swift | 40 +- iBurn/ListView/ObjectListDataProvider.swift | 22 +- iBurn/ListView/ObjectListViewModel.swift | 69 +-- iBurn/ListView/ObjectRowView.swift | 32 +- iBurn/ListView/RowAssetsLoader.swift | 2 +- iBurn/MutantVehicleImageDownloader.swift | 20 +- iBurn/PlayaDBAnnotationDataSource.swift | 24 +- iBurn/ThumbnailColors+UIKit.swift | 58 ++ iBurn/ThumbnailImageDownloader.swift | 20 +- iBurnTests/ObjectListViewModelTests.swift | 41 +- 42 files changed, 1419 insertions(+), 442 deletions(-) create mode 100644 Docs/2026-04-12-persistent-color-cache-list-rows.md create mode 100644 Packages/PlayaDB/Sources/PlayaDB/Models/ListRow.swift create mode 100644 Packages/PlayaDB/Sources/PlayaDB/Models/ThumbnailColors.swift create mode 100644 iBurn/ColorPrefetcher.swift create mode 100644 iBurn/ThumbnailColors+UIKit.swift diff --git a/Docs/2026-04-12-persistent-color-cache-list-rows.md b/Docs/2026-04-12-persistent-color-cache-list-rows.md new file mode 100644 index 00000000..dc8f452a --- /dev/null +++ b/Docs/2026-04-12-persistent-color-cache-list-rows.md @@ -0,0 +1,545 @@ +# Persistent Color Cache + Fully-Inflated List Rows + +## Status: Implementation Complete (Build Passing, 0 errors, 0 warnings) + +## Context +Previously, list views ran two separate observations (items + favorites) and computed thumbnail colors async in RowAssetsLoader. This change: + +1. **Fully-inflated row objects from the data layer** — each list row comes from the DB with `isFavorite` and `thumbnailColors` already attached, in a single read transaction. +2. **List rows**: Only Art and MV apply thumbnail-derived colors. Camps/Events use default theme. +3. **Detail views**: All types use thumbnail colors. +4. **Persistent color cache**: GRDB `thumbnail_colors` table with Codable model. +5. **Background prefetch**: Compute missing colors at launch after thumbnail downloads. +6. **Thumbnail validation**: Re-download corrupt/empty files. + +## Core Design: `ListRow` + +A fully-inflated wrapper returned from the data layer. The observation JOINs object + metadata + colors in one read transaction. Includes **full** `ObjectMetadata` (not just isFavorite) since we're doing the JOIN anyway: + +```swift +// In PlayaDB package +public struct ListRow { + public let object: T + public let metadata: ObjectMetadata? // full metadata: favorites, notes, viewed dates, etc. + public let thumbnailColors: ThumbnailColors? + + public var isFavorite: Bool { metadata?.isFavorite ?? false } +} +``` + +The observation callback delivers `[ListRow]` (not `[ArtObject]` + separate favorites set). The ViewModel stores these directly. No merging. + +``` +┌────────────────────────────────────────────────────────┐ +│ Single GRDB Read Transaction │ +│ │ +│ SELECT ao.*, om.*, tc.* │ +│ FROM art_objects ao │ +│ LEFT JOIN object_metadata om ON ... │ +│ LEFT JOIN thumbnail_colors tc ON ... │ +│ │ +│ Result: [ListRow] │ +│ ├── .object: ArtObject (name, artist, location...) │ +│ ├── .metadata: ObjectMetadata? (favorite, notes...) │ +│ └── .thumbnailColors: ThumbnailColors? │ +└────────────────────────────────────────────────────────┘ + │ + ▼ + ObjectListViewModel + @Published items: [ListRow] + │ + ▼ + ForEach(items) { row in + ObjectRowView( + object: row.object, + isFavorite: row.isFavorite, + thumbnailColors: row.thumbnailColors, + ... + ) + } +``` + +## Files Changed + +### PlayaDB Package + +| File | Action | Description | +|------|--------|-------------| +| `Models/ThumbnailColors.swift` | **NEW** | Codable GRDB model for cached colors (16 REAL columns) | +| `Models/ListRow.swift` | **NEW** | `ListRow` — fully-inflated row with object + isFavorite + colors | +| `PlayaDB.swift` | MODIFY | Replace observe methods to return `[ListRow]`; add ThumbnailColors CRUD | +| `PlayaDBImpl.swift` | MODIFY | `thumbnail_colors` table; observation impl with JOIN; CRUD | + +### App Target + +| File | Action | Description | +|------|--------|-------------| +| `ColorPrefetcher.swift` | **NEW** | Background prefetch into `thumbnail_colors` table | +| `ListView/DisplayableObject.swift` | MODIFY | Add `supportsColorTheming` (true for Art/MV) | +| `ListView/ObjectListDataProvider.swift` | MODIFY | `observeObjects` returns `AsyncStream<[ListRow]>` | +| `ListView/ArtDataProvider.swift` | MODIFY | Use new observation returning ListRow | +| `ListView/CampDataProvider.swift` | MODIFY | Use new observation returning ListRow | +| `ListView/MutantVehicleDataProvider.swift` | MODIFY | Use new observation returning ListRow | +| `ListView/EventDataProvider.swift` | MODIFY | Use new observation returning ListRow | +| `ListView/ObjectListViewModel.swift` | MODIFY | Store `[ListRow]`; remove separate favorites observation | +| `ListView/ObjectRowView.swift` | MODIFY | Accept `thumbnailColors`, gate on `supportsColorTheming` | +| `ListView/ArtListView.swift` | MODIFY | Pass row data from ListRow | +| `ListView/CampListView.swift` | MODIFY | Pass row data from ListRow | +| `ListView/MutantVehicleListView.swift` | MODIFY | Pass row data from ListRow | +| `ListView/EventListView.swift` | MODIFY | Pass row data from ListRow | +| `ListView/RowAssetsLoader.swift` | MODIFY | Read/write `thumbnail_colors` for detail views | +| `ThumbnailImageDownloader.swift` | MODIFY | Awaitable return, corrupt file validation | +| `MutantVehicleImageDownloader.swift` | MODIFY | Awaitable return, corrupt file validation | +| `DependencyContainer.swift` | MODIFY | Download → prefetch sequencing | +| Other views using ObjectRowView | MODIFY | Pass thumbnailColors param (FavoritesView, NearbyView, etc.) | + +## Detailed Plan + +### 1. NEW: `Packages/PlayaDB/.../Models/ThumbnailColors.swift` + +```swift +public struct ThumbnailColors: Codable, FetchableRecord, MutablePersistableRecord, Equatable { + public static let databaseTableName = "thumbnail_colors" + + public enum Columns: String, CodingKey, ColumnExpression { + case objectId = "object_id" + case bgRed = "bg_red", bgGreen = "bg_green", bgBlue = "bg_blue", bgAlpha = "bg_alpha" + case primaryRed = "primary_red", primaryGreen = "primary_green" + case primaryBlue = "primary_blue", primaryAlpha = "primary_alpha" + case secondaryRed = "secondary_red", secondaryGreen = "secondary_green" + case secondaryBlue = "secondary_blue", secondaryAlpha = "secondary_alpha" + case detailRed = "detail_red", detailGreen = "detail_green" + case detailBlue = "detail_blue", detailAlpha = "detail_alpha" + } + private typealias CodingKeys = Columns + + public var objectId: String // PRIMARY KEY + // 4 colors × 4 RGBA components = 16 doubles + public var bgRed: Double, bgGreen: Double, bgBlue: Double, bgAlpha: Double + public var primaryRed: Double, primaryGreen: Double, primaryBlue: Double, primaryAlpha: Double + public var secondaryRed: Double, secondaryGreen: Double, secondaryBlue: Double, secondaryAlpha: Double + public var detailRed: Double, detailGreen: Double, detailBlue: Double, detailAlpha: Double +} +``` + +### 2. NEW: `Packages/PlayaDB/.../Models/ListRow.swift` + +```swift +/// Fully-inflated row for list views. Bundles the data object with all metadata +/// needed for rendering, fetched in a single read transaction. +public struct ListRow { + public let object: T + public let metadata: ObjectMetadata? // full metadata from object_metadata table + public let thumbnailColors: ThumbnailColors? + + /// Convenience: whether this object is favorited + public var isFavorite: Bool { metadata?.isFavorite ?? false } + + public init(object: T, metadata: ObjectMetadata?, thumbnailColors: ThumbnailColors?) { + self.object = object + self.metadata = metadata + self.thumbnailColors = thumbnailColors + } +} +``` + +### 3. MODIFY: `PlayaDBImpl.swift` — Table + Observation + +**Table creation** in `setupDatabase()`: +```sql +CREATE TABLE IF NOT EXISTS thumbnail_colors ( + object_id TEXT PRIMARY KEY, + bg_red REAL NOT NULL, bg_green REAL NOT NULL, bg_blue REAL NOT NULL, bg_alpha REAL NOT NULL, + primary_red REAL NOT NULL, primary_green REAL NOT NULL, primary_blue REAL NOT NULL, primary_alpha REAL NOT NULL, + secondary_red REAL NOT NULL, secondary_green REAL NOT NULL, secondary_blue REAL NOT NULL, secondary_alpha REAL NOT NULL, + detail_red REAL NOT NULL, detail_green REAL NOT NULL, detail_blue REAL NOT NULL, detail_alpha REAL NOT NULL +) +``` + +**Annotated observation** (replaces current `observe()` helper). Single read transaction fetches objects, then batch-fetches favorites + colors for those object IDs: + +```swift +private func observeListRows( + type: DataObjectType, + value: @escaping @Sendable (Database) throws -> [T], + ids: @escaping ([T]) -> [String], + onChange: @escaping ([ListRow]) -> Void, + onError: @escaping (Error) -> Void +) -> PlayaDBObservationToken { + let observation = ValueObservation.tracking { db -> [ListRow] in + let objects = try value(db) + let objectIDs = ids(objects) + guard !objectIDs.isEmpty else { return [] } + + // Batch fetch full metadata in same transaction + let allMeta = try ObjectMetadata + .filter(ObjectMetadata.Columns.objectType == type.rawValue) + .filter(objectIDs.contains(ObjectMetadata.Columns.objectId)) + .fetchAll(db) + let metaByID = Dictionary(uniqueKeysWithValues: allMeta.map { ($0.objectId, $0) }) + + // Batch fetch colors in same transaction + let allColors = try ThumbnailColors + .filter(objectIDs.contains(ThumbnailColors.Columns.objectId)) + .fetchAll(db) + let colorsByID = Dictionary(uniqueKeysWithValues: allColors.map { ($0.objectId, $0) }) + + // Build fully-inflated rows + return objects.map { obj in + let uid = ids([obj]).first ?? "" + return ListRow( + object: obj, + metadata: metaByID[uid], + thumbnailColors: colorsByID[uid] + ) + } + } + let cancellable = observation.start(in: dbQueue, onError: onError) { [weak self] rows in + let identifiers = ids(rows.map(\.object)) + if !identifiers.isEmpty { + Task { try? await self?.ensureMetadata(for: type, ids: identifiers) } + } + onChange(rows) + } + return PlayaDBObservationToken(cancellable) +} +``` + +**Concrete methods**: `observeArt(filter:onChange:onError:)` now returns `[ListRow]` via callback. Same for camps, MV, events. + +**ThumbnailColors CRUD**: +```swift +func saveThumbnailColors(_ colors: ThumbnailColors) async throws +func saveThumbnailColorsBatch(_ batch: [ThumbnailColors]) async throws // single transaction +func fetchThumbnailColors(objectId: String) async throws -> ThumbnailColors? +``` + +### 4. MODIFY: `PlayaDB.swift` Protocol + +Update observation signatures to return `[ListRow]` with full metadata: +```swift +func observeArt(filter: ArtFilter, onChange: @escaping ([ListRow]) -> Void, onError: @escaping (Error) -> Void) -> PlayaDBObservationToken +func observeCamps(filter: CampFilter, onChange: @escaping ([ListRow]) -> Void, ...) -> PlayaDBObservationToken +func observeMutantVehicles(filter: MutantVehicleFilter, onChange: @escaping ([ListRow]) -> Void, ...) -> PlayaDBObservationToken +func observeEvents(filter: EventFilter, onChange: @escaping ([ListRow]) -> Void, ...) -> PlayaDBObservationToken + +// ThumbnailColors CRUD +func saveThumbnailColors(_ colors: ThumbnailColors) async throws +func saveThumbnailColorsBatch(_ batch: [ThumbnailColors]) async throws +func fetchThumbnailColors(objectId: String) async throws -> ThumbnailColors? +``` + +Each ListRow includes full `ObjectMetadata?` — not just isFavorite. This gives views access to favorites, notes, viewed dates, and any future metadata fields without additional queries. + +### 5. MODIFY: `DisplayableObject.swift` + +Add `supportsColorTheming`: +```swift +protocol DisplayableObject { + // ... existing ... + static var supportsColorTheming: Bool { get } +} +extension DisplayableObject { + static var supportsColorTheming: Bool { false } +} +extension ArtObject: DisplayableObject { static var supportsColorTheming: Bool { true } } +extension MutantVehicleObject: DisplayableObject { static var supportsColorTheming: Bool { true } } +// Camp, Event, EventOccurrence inherit false +``` + +### 6. MODIFY: `ObjectListDataProvider.swift` + +```swift +protocol ObjectListDataProvider { + associatedtype Object + associatedtype Filter + + func observeObjects(filter: Filter) -> AsyncStream<[ListRow]> // returns ListRow + func toggleFavorite(_ object: Object) async throws + func distanceAttributedString(from: CLLocation?, to: Object) -> AttributedString? + // Remove: func isFavorite(_ object: Object) async throws -> Bool (now on ListRow) +} +``` + +### 7. MODIFY: Concrete Data Providers + +Each provider's `observeObjects` wraps the new PlayaDB observation: +```swift +func observeObjects(filter: ArtFilter) -> AsyncStream<[ListRow]> { + AsyncStream { continuation in + let token = playaDB.observeArt(filter: filter) { rows in + continuation.yield(rows) // already [ListRow] + } onError: { error in ... } + continuation.onTermination = { _ in token.cancel() } + } +} +``` + +### 8. MODIFY: `ObjectListViewModel.swift` + +Store fully-inflated rows. Remove separate favorites observation: + +```swift +@Published var items: [ListRow] = [] +// REMOVE: @Published private(set) var favoriteIDs: Set = [] + +func isFavorite(_ object: Object) -> Bool { + items.first(where: { $0.object.uid == object.uid })?.isFavorite ?? false +} + +var filteredItems: [ListRow] { + guard !searchText.isEmpty else { return items } + let q = searchText.lowercased() + return items.filter { matchesSearch($0.object, q) } +} + +func toggleFavorite(_ row: ListRow) async { + // Optimistic update — flip isFavorite in the metadata + if let idx = items.firstIndex(where: { $0.object.uid == row.object.uid }) { + var updatedMeta = row.metadata ?? ObjectMetadata.forArt(id: row.object.uid) + updatedMeta.isFavorite = !row.isFavorite + items[idx] = ListRow(object: row.object, metadata: updatedMeta, thumbnailColors: row.thumbnailColors) + } + do { + try await dataProvider.toggleFavorite(row.object) + // Observation re-fires with correct state from DB + } catch { + // Revert on failure + if let idx = items.firstIndex(where: { $0.object.uid == row.object.uid }) { + items[idx] = row + } + } +} + +// REMOVE: startObservingFavorites() — no longer needed +// Single observation in startObserving(): +private func startObserving() { + observationTask = Task { [weak self] in + guard let self else { return } + for await rows in dataProvider.observeObjects(filter: effectiveFilter) { + await MainActor.run { + self.items = rows + if !rows.isEmpty { self.isLoading = false } + } + } + } +} +``` + +### 9. MODIFY: `ObjectRowView.swift` + +Accept optional `ThumbnailColors?`. Gate on `Object.supportsColorTheming`: + +```swift +struct ObjectRowView: View { + let object: Object + let subtitle: AttributedString? + let rightSubtitle: String? + let isFavorite: Bool + let thumbnailColors: ThumbnailColors? // NEW + let onFavoriteTap: () -> Void + @ViewBuilder let actions: (RowAssetsLoader) -> Actions + @StateObject private var assets: RowAssetsLoader + @Environment(\.themeColors) var themeColors + + var body: some View { + let colors: ImageColors = { + if Object.supportsColorTheming, let tc = thumbnailColors { + return tc.imageColors // ThumbnailColors → ImageColors conversion + } + return themeColors + }() + // ... rest uses `colors` for text + background + } + + private var listRowBackground: some View { + ZStack { + themeColors.backgroundColor + if Object.supportsColorTheming, let tc = thumbnailColors { + Color(tc.backgroundColor) // convenience computed property + .transition(.opacity) + } + } + } +} +``` + +RowAssetsLoader remains for thumbnail/audio loading. It no longer handles colors for list views. + +### 10. MODIFY: All List Views + +Pass row data from `ListRow`. Example for ArtListView: +```swift +ForEach(viewModel.filteredItems, id: \.object.uid) { row in + ObjectRowView( + object: row.object, + subtitle: viewModel.distanceAttributedString(for: row.object), + rightSubtitle: row.object.artist, + isFavorite: row.isFavorite, + thumbnailColors: row.thumbnailColors, + onFavoriteTap: { Task { await viewModel.toggleFavorite(row) } } + ) { assets in ... } +} +``` + +Same pattern for CampListView, MutantVehicleListView, EventListView, FavoritesView, NearbyView, RecentlyViewedView, GlobalSearchView, AI views. + +### 11. MODIFY: `RowAssetsLoader.swift` + +For **detail views** (which use colors for ALL types): +- Read from `thumbnail_colors` table synchronously at init (via PlayaDB) +- Write extracted colors back to `thumbnail_colors` table after async extraction +- Keep existing NSCache as L1 cache +- Make colorsCache internal for ColorPrefetcher access + +### 12. NEW: `iBurn/ColorPrefetcher.swift` + +Background prefetch into `thumbnail_colors` table. Runs after thumbnail downloads complete: + +```swift +enum ColorPrefetcher { + static func prefetchMissingColors(playaDB: PlayaDB) async { + // 1. Get all UIDs that have local thumbnails (art + camp + MV) + // 2. Fetch existing thumbnail_colors entries + // 3. Compute colors for missing entries + // 4. Batch write to DB in single transaction (one observation re-fire) + } +} +``` + +### 13. MODIFY: Downloaders + +Return awaitable `Task, Never>`. Add corrupt file validation: +```swift +@discardableResult +func downloadUncachedImages() -> Task, Never> { + // Validate existing files: check size > 0, re-download if invalid + // Return set of newly downloaded UIDs +} +``` + +### 14. MODIFY: `DependencyContainer.swift` + +Sequence: downloads → prefetch: +```swift +let mvTask = mvImageDownloader.downloadUncachedImages() +let thumbTask = thumbnailImageDownloader.downloadUncachedImages() +Task.detached(priority: .utility) { [playaDB] in + _ = await mvTask.value + _ = await thumbTask.value + await ColorPrefetcher.prefetchMissingColors(playaDB: playaDB) +} +``` + +### 15. Conversion Helpers (in app target, not PlayaDB) + +`ThumbnailColors` stores raw doubles. The app converts to UIColor/SwiftUI Color: + +```swift +// Extension in app target (ThumbnailColors is in PlayaDB, UIColor is UIKit) +extension ThumbnailColors { + var backgroundColor: UIColor { UIColor(red: bgRed, green: bgGreen, blue: bgBlue, alpha: bgAlpha) } + var primaryColor: UIColor { ... } + var secondaryColor: UIColor { ... } + var detailColor: UIColor { ... } + + var imageColors: ImageColors { + ImageColors( + backgroundColor: Color(backgroundColor), + primaryColor: Color(primaryColor), + secondaryColor: Color(secondaryColor), + detailColor: Color(detailColor) + ) + } + + init(objectId: String, brcColors: BRCImageColors) { + // Extract RGBA components from UIColors + } +} +``` + +## Implementation Order + +1. `ThumbnailColors` model + table creation in PlayaDB +2. `ListRow` struct in PlayaDB +3. ThumbnailColors CRUD in PlayaDB protocol + impl +4. Annotated observation in PlayaDB (returns `[ListRow]`) +5. `DisplayableObject.supportsColorTheming` +6. `ObjectListDataProvider` — update to return `[ListRow]` +7. Concrete data providers — use new observations +8. `ObjectListViewModel` — single observation, store `[ListRow]`, remove favorites obs +9. `ObjectRowView` — accept thumbnailColors, gate on supportsColorTheming +10. All list views — pass row data from ListRow +11. `RowAssetsLoader` — read/write thumbnail_colors for detail views +12. Conversion helpers (ThumbnailColors → UIColor/ImageColors) +13. Downloaders — awaitable + validation +14. `ColorPrefetcher` — background batch prefetch +15. `DependencyContainer` — wire up + +## Step 10 Completion: All List Views Updated to Use `ListRow` + +All list views and their hosting controllers have been updated to use `ListRow` from the data layer. + +### Files Modified + +**Type-erased item enums updated to wrap `ListRow`:** +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/NearbyItem.swift` — `NearbyItem` wraps `ListRow`, `ListRow`, `ListRow` +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/FavoriteItem.swift` — `FavoriteItem` wraps `ListRow` for all four types, added `isFavorite` and `thumbnailColors` accessors + +**View models updated to store `[ListRow]`:** +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/NearbyViewModel.swift` — `toggleFavorite` extracts `.object`, `allAnnotations` extracts `.object`, `distanceString` extracts `.object` +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/FavoritesViewModel.swift` — stored arrays changed to `[ListRow]`, filtering functions updated to access `.object`, `toggleFavorite`/`distanceAttributedString`/`allAnnotations` extract `.object`, `resolveHosts` uses `.map(\.object)` + +**List views updated (ForEach + ObjectRowView + callbacks):** +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/ArtListView.swift` — ForEach uses `\.object.uid`, row fields access `row.object.*`, `showMap` maps to `.object` +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/CampListView.swift` — same pattern +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/EventListView.swift` — `eventRow` accepts `ListRow`, `showMap` maps to `.object` +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/MutantVehicleListView.swift` — same pattern +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/NearbyView.swift` — all three cases extract `.object` for ObjectRowView and callbacks, use `.isFavorite` +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/FavoritesView.swift` — all four cases extract `.object` for ObjectRowView and callbacks, use `.isFavorite` + +**Hosting controllers updated:** +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/ArtListHostingController.swift` — `showDetail` maps `filteredItems` via `.object` +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/CampListHostingController.swift` — same +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/EventListHostingController.swift` — same +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/MutantVehicleListHostingController.swift` — same +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/FavoritesListHostingController.swift` — wraps bare objects in `ListRow(object:, metadata: nil, thumbnailColors: nil)` when constructing FavoriteItem for navigation + +**Not changed (no observation-based `ListRow` usage):** +- `RecentlyViewedItem` / `RecentlyViewedViewModel` / `RecentlyViewedView` — uses custom fetch, not observation +- `GlobalSearchHostingController` / `SearchResultItem` — uses search API, not observation +- `ObjectListViewModel` / `EventListViewModel` — already updated in prior step + +### Build Status +Build succeeds with 0 errors, 4 pre-existing warnings (Sendable, async alternatives, MainActor isolation). + +## DetailPageItem Pass-Through from List Hosting Controllers + +Updated all 6 list hosting controllers to pass pre-loaded `metadata` and `thumbnailColors` from `ListRow` data through to the detail view via `DetailPageItem`, instead of using bare `DetailSubject` arrays. + +### Changes + +**Hosting controllers updated to build `[DetailPageItem]` instead of `[DetailSubject]`:** +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/ArtListHostingController.swift` — `showDetail` builds `DetailPageItem(subject: .art(row.object), metadata: row.metadata, thumbnailColors: row.thumbnailColors)` +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/CampListHostingController.swift` — same pattern with `.camp` +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/MutantVehicleListHostingController.swift` — same with `.mutantVehicle` +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/EventListHostingController.swift` — same with `.eventOccurrence` +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/FavoritesListHostingController.swift` — uses `allFavoriteItems.map { $0.detailPageItem }` +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/NearbyListHostingController.swift` — uses `allItems.map(\.detailPageItem)`, index lookup uses `detailSubject.uid` + +**Enum types extended with `metadata` and `detailPageItem` computed properties:** +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/FavoriteItem.swift` — added `metadata: ObjectMetadata?` and `detailPageItem: DetailPageItem` +- `/Users/chrisbal/Documents/Code/iBurn-iOS/iBurn/ListView/NearbyItem.swift` — added `metadata: ObjectMetadata?` and `detailPageItem: DetailPageItem` + +### Result +Detail views now receive pre-loaded metadata and thumbnail colors from the list data, avoiding redundant DB fetches when navigating from a list to detail. Build succeeds with 0 errors. + +## Verification +1. Build and run, scroll Art list — rows show thumbnail colors +2. MV list — colored rows +3. Camp/Event lists — default/plain theme +4. Tap Camp → detail view still shows thumbnail colors +5. Kill + restart → colors load immediately from DB (no flicker) +6. Toggle a favorite in list → row updates instantly (optimistic), persists correctly +7. Only one GRDB observation per list (verify in debugger/logs) +8. ColorPrefetcher console log shows batch computation at launch diff --git a/Packages/PlayaDB/Sources/PlayaDB/Models/ListRow.swift b/Packages/PlayaDB/Sources/PlayaDB/Models/ListRow.swift new file mode 100644 index 00000000..d7c0bb44 --- /dev/null +++ b/Packages/PlayaDB/Sources/PlayaDB/Models/ListRow.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Fully-inflated row for list views. Bundles the data object with all metadata +/// needed for rendering, fetched in a single GRDB read transaction. +public struct ListRow { + public let object: T + public let metadata: ObjectMetadata? + public let thumbnailColors: ThumbnailColors? + + /// Convenience: whether this object is favorited. + public var isFavorite: Bool { metadata?.isFavorite ?? false } + + public init(object: T, metadata: ObjectMetadata?, thumbnailColors: ThumbnailColors?) { + self.object = object + self.metadata = metadata + self.thumbnailColors = thumbnailColors + } +} diff --git a/Packages/PlayaDB/Sources/PlayaDB/Models/ThumbnailColors.swift b/Packages/PlayaDB/Sources/PlayaDB/Models/ThumbnailColors.swift new file mode 100644 index 00000000..c39c7811 --- /dev/null +++ b/Packages/PlayaDB/Sources/PlayaDB/Models/ThumbnailColors.swift @@ -0,0 +1,78 @@ +import Foundation +import GRDB + +/// Cached thumbnail-extracted colors for data objects. +/// Stores RGBA components for four semantic colors: background, primary, secondary, detail. +public struct ThumbnailColors: Codable, FetchableRecord, MutablePersistableRecord, Equatable { + public static let databaseTableName = "thumbnail_colors" + + public enum Columns: String, CodingKey, ColumnExpression { + case objectId = "object_id" + case bgRed = "bg_red" + case bgGreen = "bg_green" + case bgBlue = "bg_blue" + case bgAlpha = "bg_alpha" + case primaryRed = "primary_red" + case primaryGreen = "primary_green" + case primaryBlue = "primary_blue" + case primaryAlpha = "primary_alpha" + case secondaryRed = "secondary_red" + case secondaryGreen = "secondary_green" + case secondaryBlue = "secondary_blue" + case secondaryAlpha = "secondary_alpha" + case detailRed = "detail_red" + case detailGreen = "detail_green" + case detailBlue = "detail_blue" + case detailAlpha = "detail_alpha" + } + + private typealias CodingKeys = Columns + + // MARK: - Properties + + public var objectId: String + public var bgRed: Double + public var bgGreen: Double + public var bgBlue: Double + public var bgAlpha: Double + public var primaryRed: Double + public var primaryGreen: Double + public var primaryBlue: Double + public var primaryAlpha: Double + public var secondaryRed: Double + public var secondaryGreen: Double + public var secondaryBlue: Double + public var secondaryAlpha: Double + public var detailRed: Double + public var detailGreen: Double + public var detailBlue: Double + public var detailAlpha: Double + + // MARK: - Init + + public init( + objectId: String, + bgRed: Double, bgGreen: Double, bgBlue: Double, bgAlpha: Double, + primaryRed: Double, primaryGreen: Double, primaryBlue: Double, primaryAlpha: Double, + secondaryRed: Double, secondaryGreen: Double, secondaryBlue: Double, secondaryAlpha: Double, + detailRed: Double, detailGreen: Double, detailBlue: Double, detailAlpha: Double + ) { + self.objectId = objectId + self.bgRed = bgRed + self.bgGreen = bgGreen + self.bgBlue = bgBlue + self.bgAlpha = bgAlpha + self.primaryRed = primaryRed + self.primaryGreen = primaryGreen + self.primaryBlue = primaryBlue + self.primaryAlpha = primaryAlpha + self.secondaryRed = secondaryRed + self.secondaryGreen = secondaryGreen + self.secondaryBlue = secondaryBlue + self.secondaryAlpha = secondaryAlpha + self.detailRed = detailRed + self.detailGreen = detailGreen + self.detailBlue = detailBlue + self.detailAlpha = detailAlpha + } +} diff --git a/Packages/PlayaDB/Sources/PlayaDB/PlayaDB.swift b/Packages/PlayaDB/Sources/PlayaDB/PlayaDB.swift index 85563bc0..ac797ab7 100644 --- a/Packages/PlayaDB/Sources/PlayaDB/PlayaDB.swift +++ b/Packages/PlayaDB/Sources/PlayaDB/PlayaDB.swift @@ -37,10 +37,11 @@ public protocol PlayaDB { func fetchMutantVehicle(uid: String) async throws -> MutantVehicleObject? /// Observe mutant vehicles matching the specified filter criteria. + /// Returns fully-inflated `ListRow`s with metadata and thumbnail colors. @discardableResult func observeMutantVehicles( filter: MutantVehicleFilter, - onChange: @escaping ([MutantVehicleObject]) -> Void, + onChange: @escaping ([ListRow]) -> Void, onError: @escaping (Error) -> Void ) -> PlayaDBObservationToken @@ -98,32 +99,29 @@ public protocol PlayaDB { func fetchEvents(filter: EventFilter) async throws -> [EventObjectOccurrence] /// Observe art objects matching the specified filter criteria. - /// - /// - Parameters: - /// - filter: Filter options for art objects. - /// - onChange: Called each time the underlying query results change. - /// - onError: Called if the observation encounters an error. - /// - Returns: Token for cancelling the observation. + /// Returns fully-inflated `ListRow`s with metadata and thumbnail colors. @discardableResult func observeArt( filter: ArtFilter, - onChange: @escaping ([ArtObject]) -> Void, + onChange: @escaping ([ListRow]) -> Void, onError: @escaping (Error) -> Void ) -> PlayaDBObservationToken /// Observe camp objects matching the specified filter criteria. + /// Returns fully-inflated `ListRow`s with metadata and thumbnail colors. @discardableResult func observeCamps( filter: CampFilter, - onChange: @escaping ([CampObject]) -> Void, + onChange: @escaping ([ListRow]) -> Void, onError: @escaping (Error) -> Void ) -> PlayaDBObservationToken /// Observe event occurrences matching the specified filter criteria. + /// Returns fully-inflated `ListRow`s with metadata and thumbnail colors. @discardableResult func observeEvents( filter: EventFilter, - onChange: @escaping ([EventObjectOccurrence]) -> Void, + onChange: @escaping ([ListRow]) -> Void, onError: @escaping (Error) -> Void ) -> PlayaDBObservationToken @@ -188,6 +186,20 @@ public protocol PlayaDB { /// Batch fetch objects of any type by their UIDs (4 queries total, one per type) func fetchObjects(byUIDs uids: [String]) async throws -> [any DataObject] + // MARK: - Thumbnail Colors + + /// Save (insert or replace) a single thumbnail color entry. + func saveThumbnailColors(_ colors: ThumbnailColors) async throws + + /// Save a batch of thumbnail color entries in a single transaction. + func saveThumbnailColorsBatch(_ batch: [ThumbnailColors]) async throws + + /// Fetch cached thumbnail colors for an object. + func fetchThumbnailColors(objectId: String) async throws -> ThumbnailColors? + + /// Fetch all object IDs that have cached thumbnail colors. + func fetchCachedColorObjectIDs() async throws -> Set + // MARK: - User Map Pins /// Save (insert or update) a user map pin. @@ -242,7 +254,7 @@ public extension PlayaDB { @discardableResult func observeArt( filter: ArtFilter, - onChange: @escaping ([ArtObject]) -> Void + onChange: @escaping ([ListRow]) -> Void ) -> PlayaDBObservationToken { observeArt(filter: filter, onChange: onChange, onError: { _ in }) } @@ -250,7 +262,7 @@ public extension PlayaDB { @discardableResult func observeCamps( filter: CampFilter, - onChange: @escaping ([CampObject]) -> Void + onChange: @escaping ([ListRow]) -> Void ) -> PlayaDBObservationToken { observeCamps(filter: filter, onChange: onChange, onError: { _ in }) } @@ -258,7 +270,7 @@ public extension PlayaDB { @discardableResult func observeEvents( filter: EventFilter, - onChange: @escaping ([EventObjectOccurrence]) -> Void + onChange: @escaping ([ListRow]) -> Void ) -> PlayaDBObservationToken { observeEvents(filter: filter, onChange: onChange, onError: { _ in }) } @@ -266,7 +278,7 @@ public extension PlayaDB { @discardableResult func observeMutantVehicles( filter: MutantVehicleFilter, - onChange: @escaping ([MutantVehicleObject]) -> Void + onChange: @escaping ([ListRow]) -> Void ) -> PlayaDBObservationToken { observeMutantVehicles(filter: filter, onChange: onChange, onError: { _ in }) } diff --git a/Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift b/Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift index 32174259..fd3ac045 100644 --- a/Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift +++ b/Packages/PlayaDB/Sources/PlayaDB/PlayaDBImpl.swift @@ -203,6 +203,17 @@ internal class PlayaDBImpl: PlayaDB { ) """) + // Create thumbnail_colors table for cached extracted colors + try db.execute(sql: """ + CREATE TABLE IF NOT EXISTS thumbnail_colors ( + object_id TEXT PRIMARY KEY, + bg_red REAL NOT NULL, bg_green REAL NOT NULL, bg_blue REAL NOT NULL, bg_alpha REAL NOT NULL, + primary_red REAL NOT NULL, primary_green REAL NOT NULL, primary_blue REAL NOT NULL, primary_alpha REAL NOT NULL, + secondary_red REAL NOT NULL, secondary_green REAL NOT NULL, secondary_blue REAL NOT NULL, secondary_alpha REAL NOT NULL, + detail_red REAL NOT NULL, detail_green REAL NOT NULL, detail_blue REAL NOT NULL, detail_alpha REAL NOT NULL + ) + """) + // Create user_map_pins table try db.execute(sql: """ CREATE TABLE IF NOT EXISTS user_map_pins ( @@ -1070,27 +1081,54 @@ internal class PlayaDBImpl: PlayaDB { // MARK: - Filtered Observation Helpers - private func observe( - type: DataObjectType?, + /// Observe objects as fully-inflated ListRows. Fetches objects, metadata, and + /// thumbnail colors in a single read transaction. + private func observeListRows( + type: DataObjectType, ids: @escaping ([T]) -> [String], value: @escaping @Sendable (Database) throws -> [T], - onChange: @escaping ([T]) -> Void, + onChange: @escaping ([ListRow]) -> Void, onError: @escaping (Error) -> Void ) -> PlayaDBObservationToken { - let observation = ValueObservation.tracking(value) + let typeRaw = type.rawValue + let observation = ValueObservation.tracking { db -> [ListRow] in + let objects = try value(db) + let objectIDs = ids(objects) + guard !objectIDs.isEmpty else { return [] } + + // Batch fetch full metadata in same transaction + let allMeta = try ObjectMetadata + .filter(ObjectMetadata.Columns.objectType == typeRaw) + .filter(objectIDs.contains(ObjectMetadata.Columns.objectId)) + .fetchAll(db) + let metaByID = Dictionary(uniqueKeysWithValues: allMeta.map { ($0.objectId, $0) }) + + // Batch fetch thumbnail colors in same transaction + let allColors = try ThumbnailColors + .filter(objectIDs.contains(ThumbnailColors.Columns.objectId)) + .fetchAll(db) + let colorsByID = Dictionary(uniqueKeysWithValues: allColors.map { ($0.objectId, $0) }) + + return objects.map { obj in + let uid = ids([obj]).first ?? "" + return ListRow( + object: obj, + metadata: metaByID[uid], + thumbnailColors: colorsByID[uid] + ) + } + } let cancellable = observation.start( in: dbQueue, onError: onError, - onChange: { [weak self] values in - if let type = type { - let identifiers = ids(values) - if !identifiers.isEmpty { - Task { - try? await self?.ensureMetadata(for: type, ids: identifiers) - } + onChange: { [weak self] rows in + let identifiers = ids(rows.map(\.object)) + if !identifiers.isEmpty { + Task { + try? await self?.ensureMetadata(for: type, ids: identifiers) } } - onChange(values) + onChange(rows) } ) return PlayaDBObservationToken(cancellable) @@ -1098,10 +1136,10 @@ internal class PlayaDBImpl: PlayaDB { func observeArt( filter: ArtFilter, - onChange: @escaping ([ArtObject]) -> Void, + onChange: @escaping ([ListRow]) -> Void, onError: @escaping (Error) -> Void ) -> PlayaDBObservationToken { - observe( + observeListRows( type: .art, ids: { $0.map(\.uid) }, value: { [weak self, filter] db in @@ -1115,10 +1153,10 @@ internal class PlayaDBImpl: PlayaDB { func observeCamps( filter: CampFilter, - onChange: @escaping ([CampObject]) -> Void, + onChange: @escaping ([ListRow]) -> Void, onError: @escaping (Error) -> Void ) -> PlayaDBObservationToken { - observe( + observeListRows( type: .camp, ids: { $0.map(\.uid) }, value: { [weak self, filter] db in @@ -1132,10 +1170,10 @@ internal class PlayaDBImpl: PlayaDB { func observeEvents( filter: EventFilter, - onChange: @escaping ([EventObjectOccurrence]) -> Void, + onChange: @escaping ([ListRow]) -> Void, onError: @escaping (Error) -> Void ) -> PlayaDBObservationToken { - observe( + observeListRows( type: .event, ids: { $0.map { $0.event.uid } }, value: { [weak self, filter] db in @@ -1149,10 +1187,10 @@ internal class PlayaDBImpl: PlayaDB { func observeMutantVehicles( filter: MutantVehicleFilter, - onChange: @escaping ([MutantVehicleObject]) -> Void, + onChange: @escaping ([ListRow]) -> Void, onError: @escaping (Error) -> Void ) -> PlayaDBObservationToken { - observe( + observeListRows( type: .mutantVehicle, ids: { $0.map(\.uid) }, value: { [weak self, filter] db in @@ -1164,6 +1202,38 @@ internal class PlayaDBImpl: PlayaDB { ) } + // MARK: - Thumbnail Colors + + func saveThumbnailColors(_ colors: ThumbnailColors) async throws { + try await dbQueue.write { db in + var colors = colors + try colors.save(db, onConflict: .replace) + } + } + + func saveThumbnailColorsBatch(_ batch: [ThumbnailColors]) async throws { + try await dbQueue.write { db in + for var colors in batch { + try colors.save(db, onConflict: .replace) + } + } + } + + func fetchThumbnailColors(objectId: String) async throws -> ThumbnailColors? { + try await dbQueue.read { db in + try ThumbnailColors + .filter(ThumbnailColors.Columns.objectId == objectId) + .fetchOne(db) + } + } + + func fetchCachedColorObjectIDs() async throws -> Set { + try await dbQueue.read { db in + let ids = try String.fetchAll(db, sql: "SELECT object_id FROM thumbnail_colors") + return Set(ids) + } + } + // MARK: - User Map Pins func saveUserMapPin(_ pin: UserMapPin) async throws { diff --git a/iBurn/ColorPrefetcher.swift b/iBurn/ColorPrefetcher.swift new file mode 100644 index 00000000..a7528456 --- /dev/null +++ b/iBurn/ColorPrefetcher.swift @@ -0,0 +1,71 @@ +import Foundation +import UIKit +import UIImageColors +import PlayaDB +import CocoaLumberjack + +/// Background color extraction for thumbnails. +/// Computes missing colors for all objects with local thumbnail images, +/// then batch-writes to the `thumbnail_colors` table in a single transaction. +enum ColorPrefetcher { + + /// Prefetch colors for all objects that have local thumbnails but no cached colors. + /// Should be called after thumbnail downloads complete. + static func prefetchMissingColors(playaDB: PlayaDB) async { + guard Appearance.useImageColorsTheming else { return } + + let cachedIDs: Set + do { + cachedIDs = try await playaDB.fetchCachedColorObjectIDs() + } catch { + DDLogError("ColorPrefetcher: failed to fetch cached IDs: \(error)") + cachedIDs = [] + } + + // Gather all UIDs with thumbnails (art + camp + MV) + var allUIDs: [String] = [] + if let artURLs = try? await playaDB.fetchArtImageURLs() { + allUIDs.append(contentsOf: artURLs.keys) + } + if let campURLs = try? await playaDB.fetchCampImageURLs() { + allUIDs.append(contentsOf: campURLs.keys) + } + if let mvURLs = try? await playaDB.fetchMutantVehicleImageURLs() { + allUIDs.append(contentsOf: mvURLs.keys) + } + + let needsProcessing = allUIDs.filter { uid in + !cachedIDs.contains(uid) && BRCMediaDownloader.localMediaURL("\(uid).jpg") != nil + } + + guard !needsProcessing.isEmpty else { + DDLogInfo("ColorPrefetcher: all \(allUIDs.count) objects already have cached colors") + return + } + + DDLogInfo("ColorPrefetcher: processing \(needsProcessing.count) objects") + + var batch: [ThumbnailColors] = [] + for uid in needsProcessing { + autoreleasepool { + let fileName = "\(uid).jpg" + guard let fileURL = BRCMediaDownloader.localMediaURL(fileName), + let image = UIImage(contentsOfFile: fileURL.path), + let extracted = image.getColors(quality: .high)?.brc_ImageColors + else { return } + + let tc = ThumbnailColors(objectId: uid, brcColors: extracted) + batch.append(tc) + } + } + + if !batch.isEmpty { + do { + try await playaDB.saveThumbnailColorsBatch(batch) + DDLogInfo("ColorPrefetcher: cached \(batch.count) new color entries") + } catch { + DDLogError("ColorPrefetcher: batch save failed: \(error)") + } + } + } +} diff --git a/iBurn/DependencyContainer.swift b/iBurn/DependencyContainer.swift index 9f278409..575616ac 100644 --- a/iBurn/DependencyContainer.swift +++ b/iBurn/DependencyContainer.swift @@ -88,10 +88,17 @@ class DependencyContainer { self.playaDBSeeder.seedIfNeeded() self.mvImageDownloader = MutantVehicleImageDownloader(playaDB: self.playaDB) - self.mvImageDownloader.downloadUncachedImages() + let mvTask = self.mvImageDownloader.downloadUncachedImages() self.thumbnailImageDownloader = ThumbnailImageDownloader(playaDB: self.playaDB) - self.thumbnailImageDownloader.downloadUncachedImages() + let thumbTask = self.thumbnailImageDownloader.downloadUncachedImages() + + // After downloads complete, prefetch missing thumbnail colors + Task.detached(priority: .utility) { [playaDB = self.playaDB] in + _ = await mvTask.value + _ = await thumbTask.value + await ColorPrefetcher.prefetchMissingColors(playaDB: playaDB) + } } // MARK: - Factory Methods diff --git a/iBurn/Detail/Controllers/DetailViewControllerFactory.swift b/iBurn/Detail/Controllers/DetailViewControllerFactory.swift index 5ec39a60..abe9d6db 100644 --- a/iBurn/Detail/Controllers/DetailViewControllerFactory.swift +++ b/iBurn/Detail/Controllers/DetailViewControllerFactory.swift @@ -132,6 +132,15 @@ class DetailViewControllerFactory { } static func create(with subject: DetailSubject, playaDB: PlayaDB) -> DetailHostingController { + create(with: subject, playaDB: playaDB, preloadedMetadata: nil, preloadedColors: nil) + } + + static func create( + with subject: DetailSubject, + playaDB: PlayaDB, + preloadedMetadata: ObjectMetadata?, + preloadedColors: ThumbnailColors? + ) -> DetailHostingController { let coordinator = DetailActionCoordinatorFactory.makeCoordinator() let locationService = LocationService() @@ -139,7 +148,9 @@ class DetailViewControllerFactory { subject: subject, playaDB: playaDB, locationService: locationService, - coordinator: coordinator + coordinator: coordinator, + preloadedMetadata: preloadedMetadata, + preloadedColors: preloadedColors ) let controller = DetailHostingController( diff --git a/iBurn/Detail/Models/DetailSubject.swift b/iBurn/Detail/Models/DetailSubject.swift index 1bb6426a..5d04e762 100644 --- a/iBurn/Detail/Models/DetailSubject.swift +++ b/iBurn/Detail/Models/DetailSubject.swift @@ -75,6 +75,25 @@ extension DetailSubject { } } + /// Object ID used for thumbnail and color lookup. + /// For events, resolves to the host camp or art UID when available. + var thumbnailObjectID: String { + switch self { + case .legacy(let obj): + return obj.uniqueID + case .art(let art): + return art.uid + case .camp(let camp): + return camp.uid + case .event(let event): + return event.uid + case .eventOccurrence(let occ): + return occ.hostedByCamp ?? occ.locatedAtArt ?? occ.event.uid + case .mutantVehicle(let mv): + return mv.uid + } + } + /// Text used for the "OFFICIAL LOCATION" section. /// /// Note: For PlayaDB objects this should respect embargo rules at the call site. diff --git a/iBurn/Detail/ViewModels/DetailViewModel.swift b/iBurn/Detail/ViewModels/DetailViewModel.swift index 99c788e7..c8295895 100644 --- a/iBurn/Detail/ViewModels/DetailViewModel.swift +++ b/iBurn/Detail/ViewModels/DetailViewModel.swift @@ -120,11 +120,16 @@ class DetailViewModel: ObservableObject { } /// PlayaDB-backed initializer. + /// - Parameters: + /// - preloadedMetadata: Optional pre-loaded metadata from ListRow (avoids async query on first render). + /// - preloadedColors: Optional pre-loaded thumbnail colors from ListRow (avoids async extraction). init( subject: DetailSubject, playaDB: PlayaDB, locationService: LocationServiceProtocol, coordinator: DetailActionCoordinator, + preloadedMetadata: ObjectMetadata? = nil, + preloadedColors: ThumbnailColors? = nil, mediaProvider: MediaAssetProviding = BRCMediaAssetProvider(), audioPlayer: any AudioPlayerProtocol = BRCAudioPlayer.sharedInstance ) { @@ -159,13 +164,31 @@ class DetailViewModel: ObservableObject { self.rowAssets = rowAssets self.legacyMetadata = nil - self.isFavorite = false - self.userNotes = "" - self.extractedImageColors = rowAssets?.colors + // Apply pre-loaded metadata immediately (avoids async flicker) + if let md = preloadedMetadata { + self.isFavorite = md.isFavorite + self.userNotes = md.userNotes ?? "" + self.firstViewed = md.firstViewed + self.lastViewed = md.lastViewed + } else { + self.isFavorite = false + self.userNotes = "" + } + + // Apply pre-loaded colors immediately, then fall back to RowAssetsLoader cache + if let tc = preloadedColors { + self.extractedImageColors = tc.brcImageColors + } else { + self.extractedImageColors = rowAssets?.colors + } + + // If RowAssetsLoader extracts colors later (fallback for non-preloaded case), + // update — but only if we don't already have colors rowAssets?.$colors .sink { [weak self] colors in - self?.extractedImageColors = colors + guard let self, self.extractedImageColors == nil else { return } + self.extractedImageColors = colors } .store(in: &cancellables) @@ -277,6 +300,14 @@ class DetailViewModel: ObservableObject { self.error = error } } + + // For PlayaDB subjects without pre-loaded colors, try the DB cache. + // This is faster than re-extracting from the image via RowAssetsLoader. + if extractedImageColors == nil, let playaDB { + if let tc = try? await playaDB.fetchThumbnailColors(objectId: subject.thumbnailObjectID) { + extractedImageColors = tc.brcImageColors + } + } } /// Phase 2: Expensive work (images, hosted events, setLastViewed) then refresh diff --git a/iBurn/DetailPagingDataSource.swift b/iBurn/DetailPagingDataSource.swift index 5c1808ce..420f71c1 100644 --- a/iBurn/DetailPagingDataSource.swift +++ b/iBurn/DetailPagingDataSource.swift @@ -1,23 +1,41 @@ import UIKit import PlayaDB +/// Pre-loaded data for a single detail page. +struct DetailPageItem { + let subject: DetailSubject + let metadata: ObjectMetadata? + let thumbnailColors: ThumbnailColors? + + init(subject: DetailSubject, metadata: ObjectMetadata? = nil, thumbnailColors: ThumbnailColors? = nil) { + self.subject = subject + self.metadata = metadata + self.thumbnailColors = thumbnailColors + } +} + /// Data source for swiping between detail views in a UIPageViewController. /// -/// Holds a snapshot of `DetailSubject` items captured at the moment +/// Holds a snapshot of `DetailPageItem`s captured at the moment /// the user taps a list row. The snapshot approach avoids the crashes /// that the legacy `PageViewManager` encountered when filters changed /// while the user was mid-swipe. @MainActor final class DetailPagingDataSource: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate { - private let subjects: [DetailSubject] + private let items: [DetailPageItem] private let playaDB: PlayaDB - init(subjects: [DetailSubject], playaDB: PlayaDB) { - self.subjects = subjects + init(items: [DetailPageItem], playaDB: PlayaDB) { + self.items = items self.playaDB = playaDB super.init() } + /// Convenience initializer for callers that only have bare subjects (map, deep link). + convenience init(subjects: [DetailSubject], playaDB: PlayaDB) { + self.init(items: subjects.map { DetailPageItem(subject: $0) }, playaDB: playaDB) + } + /// Creates a `DetailPageViewController` showing the item at `initialIndex`, /// with swipe navigation to adjacent items. func makePageViewController(initialIndex: Int) -> UIViewController { @@ -48,7 +66,7 @@ final class DetailPagingDataSource: NSObject, UIPageViewControllerDataSource, UI _ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController ) -> UIViewController? { - guard let index = currentIndex(of: viewController), index < subjects.count - 1 else { return nil } + guard let index = currentIndex(of: viewController), index < items.count - 1 else { return nil } return makeDetailController(at: index + 1) } @@ -67,7 +85,13 @@ final class DetailPagingDataSource: NSObject, UIPageViewControllerDataSource, UI // MARK: - Private private func makeDetailController(at index: Int) -> UIViewController { - let controller = DetailViewControllerFactory.create(with: subjects[index], playaDB: playaDB) + let item = items[index] + let controller = DetailViewControllerFactory.create( + with: item.subject, + playaDB: playaDB, + preloadedMetadata: item.metadata, + preloadedColors: item.thumbnailColors + ) controller.indexPath = IndexPath(row: index, section: 0) return controller } diff --git a/iBurn/ListView/ArtDataProvider.swift b/iBurn/ListView/ArtDataProvider.swift index 1d544c2e..b398c83b 100644 --- a/iBurn/ListView/ArtDataProvider.swift +++ b/iBurn/ListView/ArtDataProvider.swift @@ -33,16 +33,14 @@ class ArtDataProvider: ObjectListDataProvider { // MARK: - ObjectListDataProvider - func observeObjects(filter: ArtFilter) -> AsyncStream<[ArtObject]> { + func observeObjects(filter: ArtFilter) -> AsyncStream<[ListRow]> { AsyncStream { continuation in - // Observe art objects from PlayaDB - let token = playaDB.observeArt(filter: filter) { objects in - continuation.yield(objects) + let token = playaDB.observeArt(filter: filter) { rows in + continuation.yield(rows) } onError: { error in print("Art observation error: \(error)") } - // Cancel observation when stream terminates continuation.onTermination = { @Sendable _ in token.cancel() } @@ -53,10 +51,6 @@ class ArtDataProvider: ObjectListDataProvider { try await playaDB.toggleFavorite(object) } - func isFavorite(_ object: ArtObject) async throws -> Bool { - try await playaDB.isFavorite(object) - } - func distanceAttributedString(from location: CLLocation?, to object: ArtObject) -> AttributedString? { guard let location = location, let objectLocation = object.location else { diff --git a/iBurn/ListView/ArtListHostingController.swift b/iBurn/ListView/ArtListHostingController.swift index d07f4e42..abfe9695 100644 --- a/iBurn/ListView/ArtListHostingController.swift +++ b/iBurn/ListView/ArtListHostingController.swift @@ -53,9 +53,11 @@ class ArtListHostingController: UIHostingController { // MARK: - Navigation private func showDetail(for art: ArtObject) { - let subjects = viewModel.filteredItems.map { DetailSubject.art($0) } - guard let index = viewModel.filteredItems.firstIndex(where: { $0.uid == art.uid }) else { return } - let dataSource = DetailPagingDataSource(subjects: subjects, playaDB: playaDB) + let pageItems = viewModel.filteredItems.map { row in + DetailPageItem(subject: .art(row.object), metadata: row.metadata, thumbnailColors: row.thumbnailColors) + } + guard let index = viewModel.filteredItems.firstIndex(where: { $0.object.uid == art.uid }) else { return } + let dataSource = DetailPagingDataSource(items: pageItems, playaDB: playaDB) self.pagingDataSource = dataSource let pageVC = dataSource.makePageViewController(initialIndex: index) navigationController?.pushViewController(pageVC, animated: true) diff --git a/iBurn/ListView/ArtListView.swift b/iBurn/ListView/ArtListView.swift index f584c039..bc5d98cd 100644 --- a/iBurn/ListView/ArtListView.swift +++ b/iBurn/ListView/ArtListView.swift @@ -43,24 +43,25 @@ struct ArtListView: View { ZStack { // Main list content List { - ForEach(viewModel.filteredItems, id: \.uid) { art in + ForEach(viewModel.filteredItems, id: \.object.uid) { row in ObjectRowView( - object: art, - subtitle: viewModel.distanceAttributedString(for: art), - rightSubtitle: art.artist, - isFavorite: viewModel.isFavorite(art), + object: row.object, + subtitle: viewModel.distanceAttributedString(for: row.object), + rightSubtitle: row.object.artist, + isFavorite: row.isFavorite, + thumbnailColors: row.thumbnailColors, onFavoriteTap: { - Task { await viewModel.toggleFavorite(art) } + Task { await viewModel.toggleFavorite(row) } } ) { assets in if let audioURL = assets.audioURL { AudioTourButton( track: BRCAudioTourTrack( - uid: art.uid, - title: art.name, - artist: art.artist, + uid: row.object.uid, + title: row.object.name, + artist: row.object.artist, audioURL: audioURL, - artworkURL: BRCMediaDownloader.localMediaURL("\(art.uid).jpg") + artworkURL: BRCMediaDownloader.localMediaURL("\(row.object.uid).jpg") ), audioPlayer: audioPlayer ) @@ -70,7 +71,7 @@ struct ArtListView: View { } .contentShape(Rectangle()) .onTapGesture { - onSelect(art) + onSelect(row.object) } } } @@ -154,7 +155,7 @@ struct ArtListView: View { /// Show the map view with current art items private func showMap() { - onShowMap(viewModel.filteredItems) + onShowMap(viewModel.filteredItems.map(\.object)) } } @@ -223,14 +224,13 @@ private class PreviewArtDataProvider: ArtDataProvider { super.init(playaDB: try! createPlayaDB()) } - override func observeObjects(filter: ArtFilter) -> AsyncStream<[ArtObject]> { + override func observeObjects(filter: ArtFilter) -> AsyncStream<[ListRow]> { AsyncStream { continuation in - // Provide mock data for preview continuation.yield([ Self.createMockArt(name: "Temple of Transition"), Self.createMockArt(name: "The Man"), Self.createMockArt(name: "Galaxy Portal") - ]) + ].map { ListRow(object: $0, metadata: nil, thumbnailColors: nil) }) continuation.finish() } } diff --git a/iBurn/ListView/CampDataProvider.swift b/iBurn/ListView/CampDataProvider.swift index c1befcf9..836993a2 100644 --- a/iBurn/ListView/CampDataProvider.swift +++ b/iBurn/ListView/CampDataProvider.swift @@ -33,10 +33,10 @@ class CampDataProvider: ObjectListDataProvider { // MARK: - ObjectListDataProvider - func observeObjects(filter: CampFilter) -> AsyncStream<[CampObject]> { + func observeObjects(filter: CampFilter) -> AsyncStream<[ListRow]> { AsyncStream { continuation in - let token = playaDB.observeCamps(filter: filter) { objects in - continuation.yield(objects) + let token = playaDB.observeCamps(filter: filter) { rows in + continuation.yield(rows) } onError: { error in print("Camp observation error: \(error)") } @@ -51,10 +51,6 @@ class CampDataProvider: ObjectListDataProvider { try await playaDB.toggleFavorite(object) } - func isFavorite(_ object: CampObject) async throws -> Bool { - try await playaDB.isFavorite(object) - } - func distanceAttributedString(from location: CLLocation?, to object: CampObject) -> AttributedString? { guard let location = location, let objectLocation = object.location else { diff --git a/iBurn/ListView/CampListHostingController.swift b/iBurn/ListView/CampListHostingController.swift index 345aea6f..79b7b9a4 100644 --- a/iBurn/ListView/CampListHostingController.swift +++ b/iBurn/ListView/CampListHostingController.swift @@ -37,9 +37,11 @@ class CampListHostingController: UIHostingController { } private func showDetail(for camp: CampObject) { - let subjects = viewModel.filteredItems.map { DetailSubject.camp($0) } - guard let index = viewModel.filteredItems.firstIndex(where: { $0.uid == camp.uid }) else { return } - let dataSource = DetailPagingDataSource(subjects: subjects, playaDB: playaDB) + let pageItems = viewModel.filteredItems.map { row in + DetailPageItem(subject: .camp(row.object), metadata: row.metadata, thumbnailColors: row.thumbnailColors) + } + guard let index = viewModel.filteredItems.firstIndex(where: { $0.object.uid == camp.uid }) else { return } + let dataSource = DetailPagingDataSource(items: pageItems, playaDB: playaDB) self.pagingDataSource = dataSource let pageVC = dataSource.makePageViewController(initialIndex: index) navigationController?.pushViewController(pageVC, animated: true) diff --git a/iBurn/ListView/CampListView.swift b/iBurn/ListView/CampListView.swift index e4bfd38c..a89a49ec 100644 --- a/iBurn/ListView/CampListView.swift +++ b/iBurn/ListView/CampListView.swift @@ -30,21 +30,22 @@ struct CampListView: View { var body: some View { ZStack { List { - ForEach(viewModel.filteredItems, id: \.uid) { camp in + ForEach(viewModel.filteredItems, id: \.object.uid) { row in ObjectRowView( - object: camp, - subtitle: viewModel.distanceAttributedString(for: camp), - rightSubtitle: rightSubtitle(for: camp), - isFavorite: viewModel.isFavorite(camp), + object: row.object, + subtitle: viewModel.distanceAttributedString(for: row.object), + rightSubtitle: rightSubtitle(for: row.object), + isFavorite: row.isFavorite, + thumbnailColors: row.thumbnailColors, onFavoriteTap: { - Task { await viewModel.toggleFavorite(camp) } + Task { await viewModel.toggleFavorite(row) } } ) { _ in EmptyView() } .contentShape(Rectangle()) .onTapGesture { - onSelect(camp) + onSelect(row.object) } } } @@ -118,7 +119,7 @@ struct CampListView: View { } private func showMap() { - onShowMap(viewModel.filteredItems) + onShowMap(viewModel.filteredItems.map(\.object)) } private func rightSubtitle(for camp: CampObject) -> String? { @@ -162,13 +163,13 @@ private class PreviewCampDataProvider: CampDataProvider { super.init(playaDB: try! createPlayaDB()) } - override func observeObjects(filter: CampFilter) -> AsyncStream<[CampObject]> { + override func observeObjects(filter: CampFilter) -> AsyncStream<[ListRow]> { AsyncStream { continuation in continuation.yield([ Self.createMockCamp(name: "Solaris Camp"), Self.createMockCamp(name: "Dusty Mermaid"), Self.createMockCamp(name: "Roaming Oasis") - ]) + ].map { ListRow(object: $0, metadata: nil, thumbnailColors: nil) }) continuation.finish() } } diff --git a/iBurn/ListView/DisplayableObject.swift b/iBurn/ListView/DisplayableObject.swift index 72701c07..a19c1117 100644 --- a/iBurn/ListView/DisplayableObject.swift +++ b/iBurn/ListView/DisplayableObject.swift @@ -23,19 +23,28 @@ protocol DisplayableObject { /// Object ID used for thumbnail lookup (defaults to uid) var thumbnailObjectID: String { get } + + /// Whether this type uses thumbnail-derived colors in list rows. + /// Only Art and MutantVehicle return true; Camps and Events use the default theme. + static var supportsColorTheming: Bool { get } } extension DisplayableObject { var thumbnailObjectID: String { uid } + static var supportsColorTheming: Bool { false } } // Extend PlayaDB types to conform to DisplayableObject import PlayaDB -extension ArtObject: DisplayableObject {} +extension ArtObject: DisplayableObject { + static var supportsColorTheming: Bool { true } +} extension CampObject: DisplayableObject {} extension EventObject: DisplayableObject {} -extension MutantVehicleObject: DisplayableObject {} +extension MutantVehicleObject: DisplayableObject { + static var supportsColorTheming: Bool { true } +} extension EventObjectOccurrence: DisplayableObject { var thumbnailObjectID: String { diff --git a/iBurn/ListView/EventDataProvider.swift b/iBurn/ListView/EventDataProvider.swift index f7f4a31a..f717d61b 100644 --- a/iBurn/ListView/EventDataProvider.swift +++ b/iBurn/ListView/EventDataProvider.swift @@ -23,10 +23,10 @@ class EventDataProvider: ObjectListDataProvider { // MARK: - ObjectListDataProvider - func observeObjects(filter: EventFilter) -> AsyncStream<[EventObjectOccurrence]> { + func observeObjects(filter: EventFilter) -> AsyncStream<[ListRow]> { AsyncStream { continuation in - let token = playaDB.observeEvents(filter: filter) { objects in - continuation.yield(objects) + let token = playaDB.observeEvents(filter: filter) { rows in + continuation.yield(rows) } onError: { error in print("Event observation error: \(error)") } @@ -41,10 +41,6 @@ class EventDataProvider: ObjectListDataProvider { try await playaDB.toggleFavorite(object) } - func isFavorite(_ object: EventObjectOccurrence) async throws -> Bool { - try await playaDB.isFavorite(object) - } - func distanceAttributedString(from location: CLLocation?, to object: EventObjectOccurrence) -> AttributedString? { guard let location = location, let objectLocation = object.location else { diff --git a/iBurn/ListView/EventListHostingController.swift b/iBurn/ListView/EventListHostingController.swift index 21798f14..50cbf110 100644 --- a/iBurn/ListView/EventListHostingController.swift +++ b/iBurn/ListView/EventListHostingController.swift @@ -35,9 +35,11 @@ class EventListHostingController: UIHostingController { // MARK: - Navigation private func showDetail(for event: EventObjectOccurrence) { - let subjects = viewModel.filteredItems.map { DetailSubject.eventOccurrence($0) } - guard let index = viewModel.filteredItems.firstIndex(where: { $0.event.uid == event.event.uid && $0.occurrence.startTime == event.occurrence.startTime }) else { return } - let dataSource = DetailPagingDataSource(subjects: subjects, playaDB: playaDB) + let pageItems = viewModel.filteredItems.map { row in + DetailPageItem(subject: .eventOccurrence(row.object), metadata: row.metadata, thumbnailColors: row.thumbnailColors) + } + guard let index = viewModel.filteredItems.firstIndex(where: { $0.object.event.uid == event.event.uid && $0.object.occurrence.startTime == event.occurrence.startTime }) else { return } + let dataSource = DetailPagingDataSource(items: pageItems, playaDB: playaDB) self.pagingDataSource = dataSource let pageVC = dataSource.makePageViewController(initialIndex: index) navigationController?.pushViewController(pageVC, animated: true) diff --git a/iBurn/ListView/EventListView.swift b/iBurn/ListView/EventListView.swift index c2935a7c..a468ed11 100644 --- a/iBurn/ListView/EventListView.swift +++ b/iBurn/ListView/EventListView.swift @@ -34,11 +34,11 @@ struct EventListView: View { List { ForEach(viewModel.groupedItems, id: \.header) { group in Section(header: Text(group.header)) { - ForEach(group.items, id: \.uid) { event in + ForEach(group.items, id: \.object.uid) { row in Button { - onSelect(event) + onSelect(row.object) } label: { - eventRow(for: event) + eventRow(for: row) } .buttonStyle(.plain) } @@ -115,17 +115,18 @@ struct EventListView: View { // MARK: - Row Builder - private func eventRow(for event: EventObjectOccurrence) -> some View { + private func eventRow(for row: ListRow) -> some View { return ObjectRowView( - object: event, - subtitle: viewModel.distanceAttributedString(for: event), - rightSubtitle: event.timeDescription(now: viewModel.now), - isFavorite: viewModel.isFavorite(event), + object: row.object, + subtitle: viewModel.distanceAttributedString(for: row.object), + rightSubtitle: row.object.timeDescription(now: viewModel.now), + isFavorite: row.isFavorite, + thumbnailColors: row.thumbnailColors, onFavoriteTap: { - Task { await viewModel.toggleFavorite(event) } + Task { await viewModel.toggleFavorite(row) } } ) { _ in - Text(EventTypeInfo.emoji(for: event.eventTypeCode)) + Text(EventTypeInfo.emoji(for: row.object.eventTypeCode)) .font(.subheadline) } } @@ -142,6 +143,6 @@ struct EventListView: View { } private func showMap() { - onShowMap(viewModel.filteredItems) + onShowMap(viewModel.filteredItems.map(\.object)) } } diff --git a/iBurn/ListView/EventListViewModel.swift b/iBurn/ListView/EventListViewModel.swift index 89e09317..3c418e08 100644 --- a/iBurn/ListView/EventListViewModel.swift +++ b/iBurn/ListView/EventListViewModel.swift @@ -16,7 +16,7 @@ struct ResolvedEventHost { final class EventListViewModel: ObservableObject { // MARK: - Published - @Published var items: [EventObjectOccurrence] = [] + @Published var items: [ListRow] = [] @Published var filter: EventFilter { didSet { @@ -28,8 +28,6 @@ final class EventListViewModel: ObservableObject { @Published var searchText: String = "" @Published var isLoading: Bool = true @Published var currentLocation: CLLocation? - @Published private(set) var favoriteIDs: Set = [] - /// Resolved host data for events (event UID → host info) @Published private(set) var resolvedHosts: [String: ResolvedEventHost] = [:] @@ -56,7 +54,6 @@ final class EventListViewModel: ObservableObject { private var observationTask: Task? private var locationTask: Task? - private var favoritesObservationTask: Task? private var loadingGateTask: Task? private var timerTask: Task? @@ -83,7 +80,6 @@ final class EventListViewModel: ObservableObject { self.currentLocation = locationProvider.currentLocation startObserving() - startObservingFavorites() startLocationUpdates() startRefreshTimer() } @@ -91,7 +87,6 @@ final class EventListViewModel: ObservableObject { deinit { observationTask?.cancel() locationTask?.cancel() - favoritesObservationTask?.cancel() loadingGateTask?.cancel() timerTask?.cancel() } @@ -99,7 +94,7 @@ final class EventListViewModel: ObservableObject { // MARK: - Derived func isFavorite(_ object: EventObjectOccurrence) -> Bool { - favoriteIDs.contains(object.uid) + items.first(where: { $0.object.uid == object.uid })?.isFavorite ?? false } func distanceAttributedString(for object: EventObjectOccurrence) -> AttributedString? { @@ -119,55 +114,51 @@ final class EventListViewModel: ObservableObject { return event.event.hasOtherLocation ? event.event.otherLocation : nil } - var filteredItems: [EventObjectOccurrence] { + var filteredItems: [ListRow] { guard !searchText.isEmpty else { return items } let q = searchText.lowercased() return items.filter { - $0.name.lowercased().contains(q) || - $0.description?.lowercased().contains(q) == true || - $0.eventTypeLabel.lowercased().contains(q) == true || - $0.hostedByCamp?.lowercased().contains(q) == true + $0.object.name.lowercased().contains(q) || + $0.object.description?.lowercased().contains(q) == true || + $0.object.eventTypeLabel.lowercased().contains(q) == true || + $0.object.hostedByCamp?.lowercased().contains(q) == true } } /// Items grouped by hour for sectioned display - var groupedItems: [(header: String, items: [EventObjectOccurrence])] { + var groupedItems: [(header: String, items: [ListRow])] { let filtered = filteredItems guard !filtered.isEmpty else { return [] } let calendar = Calendar.current - let grouped = Dictionary(grouping: filtered) { event -> Int in - calendar.component(.hour, from: event.startDate) + let grouped = Dictionary(grouping: filtered) { row -> Int in + calendar.component(.hour, from: row.object.startDate) } return grouped .sorted { $0.key < $1.key } - .map { (hour, events) in + .map { (hour, rows) in let displayHour = hour % 12 == 0 ? 12 : hour % 12 let ampm = hour >= 12 ? "PM" : "AM" - return (header: "\(displayHour) \(ampm)", items: events) + return (header: "\(displayHour) \(ampm)", items: rows) } } // MARK: - Actions - func toggleFavorite(_ object: EventObjectOccurrence) async { - let occurrenceUID = object.uid - let desiredIsFavorite = !favoriteIDs.contains(occurrenceUID) - if desiredIsFavorite { - favoriteIDs.insert(occurrenceUID) - } else { - favoriteIDs.remove(occurrenceUID) + func toggleFavorite(_ row: ListRow) async { + let originalRow = row + if let idx = items.firstIndex(where: { $0.object.uid == row.object.uid }) { + var updatedMeta = row.metadata + updatedMeta?.isFavorite = !row.isFavorite + items[idx] = ListRow(object: row.object, metadata: updatedMeta, thumbnailColors: row.thumbnailColors) } do { - try await dataProvider.toggleFavorite(object) + try await dataProvider.toggleFavorite(row.object) } catch { - // Revert optimistic UI if write fails. - if desiredIsFavorite { - favoriteIDs.remove(occurrenceUID) - } else { - favoriteIDs.insert(occurrenceUID) + if let idx = items.firstIndex(where: { $0.object.uid == originalRow.object.uid }) { + items[idx] = originalRow } - print("Error toggling favorite for \(object.name): \(error)") + print("Error toggling favorite for \(row.object.name): \(error)") } } @@ -195,19 +186,19 @@ final class EventListViewModel: ObservableObject { guard let self else { return } var didReceiveFirstEmission = false - for await observedItems in self.dataProvider.observeObjects(filter: filterForObservation) { + for await rows in self.dataProvider.observeObjects(filter: filterForObservation) { didReceiveFirstEmission = true await MainActor.run { - self.items = observedItems - if !observedItems.isEmpty { + self.items = rows + if !rows.isEmpty { self.isLoading = false } - self.resolveHosts(for: observedItems) + self.resolveHosts(for: rows.map(\.object)) } - if didReceiveFirstEmission, !observedItems.isEmpty { + if didReceiveFirstEmission, !rows.isEmpty { loadingGateTask?.cancel() - } else if didReceiveFirstEmission, observedItems.isEmpty { + } else if didReceiveFirstEmission, rows.isEmpty { startLoadingGateIfNeeded() } } @@ -261,34 +252,6 @@ final class EventListViewModel: ObservableObject { private func restartObservation() { startObserving() - startObservingFavorites() - } - - // MARK: - Favorites - - private func startObservingFavorites() { - favoritesObservationTask?.cancel() - - // Build a favorites-only filter (clears time/search constraints) - var favFilter = filter - favFilter.searchText = nil - favFilter.happeningNow = false - favFilter.includeExpired = true - favFilter.startingWithinHours = nil - favFilter.startDate = nil - favFilter.endDate = nil - favFilter.eventTypeCodes = nil - favFilter.onlyFavorites = true - - favoritesObservationTask = Task { [weak self] in - guard let self else { return } - for await favorites in self.dataProvider.observeObjects(filter: favFilter) { - let ids = Set(favorites.map(\.uid)) - await MainActor.run { - self.favoriteIDs = ids - } - } - } } // MARK: - Host Resolution diff --git a/iBurn/ListView/FavoriteItem.swift b/iBurn/ListView/FavoriteItem.swift index 29f1eebc..b7d55cab 100644 --- a/iBurn/ListView/FavoriteItem.swift +++ b/iBurn/ListView/FavoriteItem.swift @@ -12,36 +12,36 @@ enum FavoritesTypeFilter: String, CaseIterable, Codable { /// Type-safe wrapper for a favorited object of any type enum FavoriteItem: Identifiable { - case art(ArtObject) - case camp(CampObject) - case event(EventObjectOccurrence) - case mutantVehicle(MutantVehicleObject) + case art(ListRow) + case camp(ListRow) + case event(ListRow) + case mutantVehicle(ListRow) var id: String { uid } var uid: String { switch self { - case .art(let o): o.uid - case .camp(let o): o.uid - case .event(let o): o.uid - case .mutantVehicle(let o): o.uid + case .art(let r): r.object.uid + case .camp(let r): r.object.uid + case .event(let r): r.object.uid + case .mutantVehicle(let r): r.object.uid } } var name: String { switch self { - case .art(let o): o.name - case .camp(let o): o.name - case .event(let o): o.name - case .mutantVehicle(let o): o.name + case .art(let r): r.object.name + case .camp(let r): r.object.name + case .event(let r): r.object.name + case .mutantVehicle(let r): r.object.name } } var location: CLLocation? { switch self { - case .art(let o): o.location - case .camp(let o): o.location - case .event(let o): o.location + case .art(let r): r.object.location + case .camp(let r): r.object.location + case .event(let r): r.object.location case .mutantVehicle: nil } } @@ -57,10 +57,41 @@ enum FavoriteItem: Identifiable { var detailSubject: DetailSubject { switch self { - case .art(let o): .art(o) - case .camp(let o): .camp(o) - case .event(let o): .eventOccurrence(o) - case .mutantVehicle(let o): .mutantVehicle(o) + case .art(let r): .art(r.object) + case .camp(let r): .camp(r.object) + case .event(let r): .eventOccurrence(r.object) + case .mutantVehicle(let r): .mutantVehicle(r.object) + } + } + + var metadata: ObjectMetadata? { + switch self { + case .art(let r): r.metadata + case .camp(let r): r.metadata + case .event(let r): r.metadata + case .mutantVehicle(let r): r.metadata + } + } + + var detailPageItem: DetailPageItem { + DetailPageItem(subject: detailSubject, metadata: metadata, thumbnailColors: thumbnailColors) + } + + var isFavorite: Bool { + switch self { + case .art(let r): r.isFavorite + case .camp(let r): r.isFavorite + case .event(let r): r.isFavorite + case .mutantVehicle(let r): r.isFavorite + } + } + + var thumbnailColors: ThumbnailColors? { + switch self { + case .art(let r): r.thumbnailColors + case .camp(let r): r.thumbnailColors + case .event(let r): r.thumbnailColors + case .mutantVehicle(let r): r.thumbnailColors } } } diff --git a/iBurn/ListView/FavoritesListHostingController.swift b/iBurn/ListView/FavoritesListHostingController.swift index 17de1325..09926772 100644 --- a/iBurn/ListView/FavoritesListHostingController.swift +++ b/iBurn/ListView/FavoritesListHostingController.swift @@ -19,16 +19,16 @@ class FavoritesListHostingController: UIHostingController { self.rootView = FavoritesView( viewModel: viewModel, onSelectArt: { [weak self] art in - self?.showDetail(for: .art(art)) + self?.showDetail(for: .art(ListRow(object: art, metadata: nil, thumbnailColors: nil))) }, onSelectCamp: { [weak self] camp in - self?.showDetail(for: .camp(camp)) + self?.showDetail(for: .camp(ListRow(object: camp, metadata: nil, thumbnailColors: nil))) }, onSelectEvent: { [weak self] event in - self?.showDetail(for: .event(event)) + self?.showDetail(for: .event(ListRow(object: event, metadata: nil, thumbnailColors: nil))) }, onSelectMV: { [weak self] mv in - self?.showDetail(for: .mutantVehicle(mv)) + self?.showDetail(for: .mutantVehicle(ListRow(object: mv, metadata: nil, thumbnailColors: nil))) }, onShowMap: { [weak self] annotations in self?.showMap(annotations: annotations) @@ -45,9 +45,9 @@ class FavoritesListHostingController: UIHostingController { private func showDetail(for item: FavoriteItem) { let allItems = viewModel.allFavoriteItems - let subjects = allItems.map { $0.detailSubject } + let pageItems = allItems.map { $0.detailPageItem } guard let index = allItems.firstIndex(where: { $0.uid == item.uid }) else { return } - let dataSource = DetailPagingDataSource(subjects: subjects, playaDB: playaDB) + let dataSource = DetailPagingDataSource(items: pageItems, playaDB: playaDB) self.pagingDataSource = dataSource let pageVC = dataSource.makePageViewController(initialIndex: index) navigationController?.pushViewController(pageVC, animated: true) @@ -77,11 +77,11 @@ class FavoritesListHostingController: UIHostingController { switch anyID { case .art(let id): if let art = try? await playaDB.fetchArt(uid: id.value) { - showDetail(for: .art(art)) + showDetail(for: .art(ListRow(object: art, metadata: nil, thumbnailColors: nil))) } case .camp(let id): if let camp = try? await playaDB.fetchCamp(uid: id.value) { - showDetail(for: .camp(camp)) + showDetail(for: .camp(ListRow(object: camp, metadata: nil, thumbnailColors: nil))) } case .event(let id): if let event = try? await playaDB.fetchEvent(uid: id.value) { @@ -90,7 +90,7 @@ class FavoritesListHostingController: UIHostingController { } case .mutantVehicle(let id): if let mv = try? await playaDB.fetchMutantVehicle(uid: id.value) { - showDetail(for: .mutantVehicle(mv)) + showDetail(for: .mutantVehicle(ListRow(object: mv, metadata: nil, thumbnailColors: nil))) } } } diff --git a/iBurn/ListView/FavoritesView.swift b/iBurn/ListView/FavoritesView.swift index 6e0618f1..a373ac3d 100644 --- a/iBurn/ListView/FavoritesView.swift +++ b/iBurn/ListView/FavoritesView.swift @@ -135,50 +135,54 @@ struct FavoritesView: View { switch item { case .art(let art): ObjectRowView( - object: art, + object: art.object, subtitle: viewModel.distanceAttributedString(for: item), - rightSubtitle: art.artist, - isFavorite: true, + rightSubtitle: art.object.artist, + isFavorite: art.isFavorite, + thumbnailColors: item.thumbnailColors, onFavoriteTap: { Task { await viewModel.toggleFavorite(item) } } ) { _ in EmptyView() } .contentShape(Rectangle()) - .onTapGesture { onSelectArt(art) } + .onTapGesture { onSelectArt(art.object) } case .camp(let camp): ObjectRowView( - object: camp, + object: camp.object, subtitle: viewModel.distanceAttributedString(for: item), - rightSubtitle: camp.hometown, - isFavorite: true, + rightSubtitle: camp.object.hometown, + isFavorite: camp.isFavorite, + thumbnailColors: item.thumbnailColors, onFavoriteTap: { Task { await viewModel.toggleFavorite(item) } } ) { _ in EmptyView() } .contentShape(Rectangle()) - .onTapGesture { onSelectCamp(camp) } + .onTapGesture { onSelectCamp(camp.object) } case .event(let event): ObjectRowView( - object: event, + object: event.object, subtitle: viewModel.distanceAttributedString(for: .event(event)), - rightSubtitle: event.timeDescription(now: viewModel.now), - isFavorite: true, + rightSubtitle: event.object.timeDescription(now: viewModel.now), + isFavorite: event.isFavorite, + thumbnailColors: item.thumbnailColors, onFavoriteTap: { Task { await viewModel.toggleFavorite(.event(event)) } } ) { _ in - Text(EventTypeInfo.emoji(for: event.eventTypeCode)) + Text(EventTypeInfo.emoji(for: event.object.eventTypeCode)) .font(.subheadline) } .contentShape(Rectangle()) - .onTapGesture { onSelectEvent(event) } + .onTapGesture { onSelectEvent(event.object) } case .mutantVehicle(let mv): ObjectRowView( - object: mv, + object: mv.object, subtitle: nil, - rightSubtitle: mv.artist, - isFavorite: true, + rightSubtitle: mv.object.artist, + isFavorite: mv.isFavorite, + thumbnailColors: item.thumbnailColors, onFavoriteTap: { Task { await viewModel.toggleFavorite(item) } } ) { _ in EmptyView() } .contentShape(Rectangle()) - .onTapGesture { onSelectMV(mv) } + .onTapGesture { onSelectMV(mv.object) } } } diff --git a/iBurn/ListView/FavoritesViewModel.swift b/iBurn/ListView/FavoritesViewModel.swift index 082ad724..79b95466 100644 --- a/iBurn/ListView/FavoritesViewModel.swift +++ b/iBurn/ListView/FavoritesViewModel.swift @@ -7,10 +7,10 @@ import PlayaDB final class FavoritesViewModel: ObservableObject { // MARK: - Published - @Published var artItems: [ArtObject] = [] - @Published var campItems: [CampObject] = [] - @Published var eventItems: [EventObjectOccurrence] = [] - @Published var mvItems: [MutantVehicleObject] = [] + @Published var artItems: [ListRow] = [] + @Published var campItems: [ListRow] = [] + @Published var eventItems: [ListRow] = [] + @Published var mvItems: [ListRow] = [] @Published var selectedTypeFilter: FavoritesTypeFilter { didSet { @@ -128,39 +128,39 @@ final class FavoritesViewModel: ObservableObject { // MARK: - Search Filtering - private func filteredArt(_ q: String) -> [ArtObject] { + private func filteredArt(_ q: String) -> [ListRow] { guard !q.isEmpty else { return artItems } return artItems.filter { - $0.name.lowercased().contains(q) || - $0.description?.lowercased().contains(q) == true || - $0.artist?.lowercased().contains(q) == true + $0.object.name.lowercased().contains(q) || + $0.object.description?.lowercased().contains(q) == true || + $0.object.artist?.lowercased().contains(q) == true } } - private func filteredCamps(_ q: String) -> [CampObject] { + private func filteredCamps(_ q: String) -> [ListRow] { guard !q.isEmpty else { return campItems } return campItems.filter { - $0.name.lowercased().contains(q) || - $0.description?.lowercased().contains(q) == true || - $0.hometown?.lowercased().contains(q) == true + $0.object.name.lowercased().contains(q) || + $0.object.description?.lowercased().contains(q) == true || + $0.object.hometown?.lowercased().contains(q) == true } } - private func filteredEvents(_ q: String) -> [EventObjectOccurrence] { + private func filteredEvents(_ q: String) -> [ListRow] { guard !q.isEmpty else { return eventItems } return eventItems.filter { - $0.name.lowercased().contains(q) || - $0.description?.lowercased().contains(q) == true || - $0.eventTypeLabel.lowercased().contains(q) == true + $0.object.name.lowercased().contains(q) || + $0.object.description?.lowercased().contains(q) == true || + $0.object.eventTypeLabel.lowercased().contains(q) == true } } - private func filteredMVs(_ q: String) -> [MutantVehicleObject] { + private func filteredMVs(_ q: String) -> [ListRow] { guard !q.isEmpty else { return mvItems } return mvItems.filter { - $0.name.lowercased().contains(q) || - $0.description?.lowercased().contains(q) == true || - $0.artist?.lowercased().contains(q) == true + $0.object.name.lowercased().contains(q) || + $0.object.description?.lowercased().contains(q) == true || + $0.object.artist?.lowercased().contains(q) == true } } @@ -173,9 +173,9 @@ final class FavoritesViewModel: ObservableObject { func distanceAttributedString(for item: FavoriteItem) -> AttributedString? { switch item { - case .art(let o): artProvider.distanceAttributedString(from: currentLocation, to: o) - case .camp(let o): campProvider.distanceAttributedString(from: currentLocation, to: o) - case .event(let o): eventProvider.distanceAttributedString(from: currentLocation, to: o) + case .art(let r): artProvider.distanceAttributedString(from: currentLocation, to: r.object) + case .camp(let r): campProvider.distanceAttributedString(from: currentLocation, to: r.object) + case .event(let r): eventProvider.distanceAttributedString(from: currentLocation, to: r.object) case .mutantVehicle: nil } } @@ -196,10 +196,10 @@ final class FavoritesViewModel: ObservableObject { func toggleFavorite(_ item: FavoriteItem) async { do { switch item { - case .art(let o): try await artProvider.toggleFavorite(o) - case .camp(let o): try await campProvider.toggleFavorite(o) - case .event(let o): try await eventProvider.toggleFavorite(o) - case .mutantVehicle(let o): try await mvProvider.toggleFavorite(o) + case .art(let r): try await artProvider.toggleFavorite(r.object) + case .camp(let r): try await campProvider.toggleFavorite(r.object) + case .event(let r): try await eventProvider.toggleFavorite(r.object) + case .mutantVehicle(let r): try await mvProvider.toggleFavorite(r.object) } } catch { print("Error toggling favorite for \(item.name): \(error)") @@ -218,12 +218,12 @@ final class FavoritesViewModel: ObservableObject { for section in sections { for item in section.items { switch item { - case .art(let o): - if let a = PlayaObjectAnnotation(art: o) { annotations.append(a) } - case .camp(let o): - if let a = PlayaObjectAnnotation(camp: o) { annotations.append(a) } - case .event(let o): - if let a = PlayaObjectAnnotation(event: o) { annotations.append(a) } + case .art(let r): + if let a = PlayaObjectAnnotation(art: r.object) { annotations.append(a) } + case .camp(let r): + if let a = PlayaObjectAnnotation(camp: r.object) { annotations.append(a) } + case .event(let r): + if let a = PlayaObjectAnnotation(event: r.object) { annotations.append(a) } case .mutantVehicle: break // No location } @@ -289,7 +289,7 @@ final class FavoritesViewModel: ObservableObject { await MainActor.run { self.eventItems = items self.markReceived("event") - self.resolveHosts(for: items) + self.resolveHosts(for: items.map(\.object)) } } } diff --git a/iBurn/ListView/MutantVehicleDataProvider.swift b/iBurn/ListView/MutantVehicleDataProvider.swift index 4bf79c02..72bfa2b5 100644 --- a/iBurn/ListView/MutantVehicleDataProvider.swift +++ b/iBurn/ListView/MutantVehicleDataProvider.swift @@ -17,10 +17,10 @@ class MutantVehicleDataProvider: ObjectListDataProvider { return !updateInfo.isEmpty } - func observeObjects(filter: MutantVehicleFilter) -> AsyncStream<[MutantVehicleObject]> { + func observeObjects(filter: MutantVehicleFilter) -> AsyncStream<[ListRow]> { AsyncStream { continuation in - let token = playaDB.observeMutantVehicles(filter: filter) { objects in - continuation.yield(objects) + let token = playaDB.observeMutantVehicles(filter: filter) { rows in + continuation.yield(rows) } onError: { error in print("MV observation error: \(error)") } @@ -35,10 +35,6 @@ class MutantVehicleDataProvider: ObjectListDataProvider { try await playaDB.toggleFavorite(object) } - func isFavorite(_ object: MutantVehicleObject) async throws -> Bool { - try await playaDB.isFavorite(object) - } - func distanceAttributedString(from location: CLLocation?, to object: MutantVehicleObject) -> AttributedString? { nil } diff --git a/iBurn/ListView/MutantVehicleListHostingController.swift b/iBurn/ListView/MutantVehicleListHostingController.swift index a43db836..dd0b60a0 100644 --- a/iBurn/ListView/MutantVehicleListHostingController.swift +++ b/iBurn/ListView/MutantVehicleListHostingController.swift @@ -26,9 +26,11 @@ class MutantVehicleListHostingController: UIHostingController) + case camp(ListRow) + case event(ListRow) var id: String { switch self { - case .art(let o): "art-\(o.uid)" - case .camp(let o): "camp-\(o.uid)" - case .event(let o): "event-\(o.uid)" + case .art(let r): "art-\(r.object.uid)" + case .camp(let r): "camp-\(r.object.uid)" + case .event(let r): "event-\(r.object.uid)" } } var name: String { switch self { - case .art(let o): o.name - case .camp(let o): o.name - case .event(let o): o.name + case .art(let r): r.object.name + case .camp(let r): r.object.name + case .event(let r): r.object.name } } var location: CLLocation? { switch self { - case .art(let o): o.location - case .camp(let o): o.location - case .event(let o): o.location + case .art(let r): r.object.location + case .camp(let r): r.object.location + case .event(let r): r.object.location } } var detailSubject: DetailSubject { switch self { - case .art(let o): .art(o) - case .camp(let o): .camp(o) - case .event(let o): .eventOccurrence(o) + case .art(let r): .art(r.object) + case .camp(let r): .camp(r.object) + case .event(let r): .eventOccurrence(r.object) + } + } + + var metadata: ObjectMetadata? { + switch self { + case .art(let r): r.metadata + case .camp(let r): r.metadata + case .event(let r): r.metadata + } + } + + var detailPageItem: DetailPageItem { + DetailPageItem(subject: detailSubject, metadata: metadata, thumbnailColors: thumbnailColors) + } + + var isFavorite: Bool { + switch self { + case .art(let r): r.isFavorite + case .camp(let r): r.isFavorite + case .event(let r): r.isFavorite + } + } + + var thumbnailColors: ThumbnailColors? { + switch self { + case .art(let r): r.thumbnailColors + case .camp(let r): r.thumbnailColors + case .event(let r): r.thumbnailColors } } } diff --git a/iBurn/ListView/NearbyListHostingController.swift b/iBurn/ListView/NearbyListHostingController.swift index 49af4497..5e87c8e8 100644 --- a/iBurn/ListView/NearbyListHostingController.swift +++ b/iBurn/ListView/NearbyListHostingController.swift @@ -61,10 +61,10 @@ class NearbyListHostingController: UIHostingController { private func showDetail(_ subject: DetailSubject) { let allItems = viewModel.sections.flatMap(\.items) - let subjects = allItems.map(\.detailSubject) + let pageItems = allItems.map(\.detailPageItem) let uid = subject.uid - if let index = subjects.firstIndex(where: { $0.uid == uid }) { - let dataSource = DetailPagingDataSource(subjects: subjects, playaDB: playaDB) + if let index = allItems.firstIndex(where: { $0.detailSubject.uid == uid }) { + let dataSource = DetailPagingDataSource(items: pageItems, playaDB: playaDB) self.pagingDataSource = dataSource let pageVC = dataSource.makePageViewController(initialIndex: index) navigationController?.pushViewController(pageVC, animated: true) diff --git a/iBurn/ListView/NearbyView.swift b/iBurn/ListView/NearbyView.swift index 231d8d64..6c4288e4 100644 --- a/iBurn/ListView/NearbyView.swift +++ b/iBurn/ListView/NearbyView.swift @@ -175,39 +175,42 @@ struct NearbyView: View { switch item { case .art(let art): ObjectRowView( - object: art, + object: art.object, subtitle: viewModel.distanceString(for: item), - rightSubtitle: art.artist, - isFavorite: false, + rightSubtitle: art.object.artist, + isFavorite: art.isFavorite, + thumbnailColors: item.thumbnailColors, onFavoriteTap: { Task { await viewModel.toggleFavorite(item) } } ) { _ in EmptyView() } .contentShape(Rectangle()) - .onTapGesture { onSelectArt(art) } + .onTapGesture { onSelectArt(art.object) } case .camp(let camp): ObjectRowView( - object: camp, + object: camp.object, subtitle: viewModel.distanceString(for: item), - rightSubtitle: camp.hometown, - isFavorite: false, + rightSubtitle: camp.object.hometown, + isFavorite: camp.isFavorite, + thumbnailColors: item.thumbnailColors, onFavoriteTap: { Task { await viewModel.toggleFavorite(item) } } ) { _ in EmptyView() } .contentShape(Rectangle()) - .onTapGesture { onSelectCamp(camp) } + .onTapGesture { onSelectCamp(camp.object) } case .event(let event): ObjectRowView( - object: event, + object: event.object, subtitle: viewModel.distanceString(for: .event(event)), - rightSubtitle: event.timeDescription(now: viewModel.now), - isFavorite: false, + rightSubtitle: event.object.timeDescription(now: viewModel.now), + isFavorite: event.isFavorite, + thumbnailColors: item.thumbnailColors, onFavoriteTap: { Task { await viewModel.toggleFavorite(.event(event)) } } ) { _ in - Text(EventTypeInfo.emoji(for: event.eventTypeCode)) + Text(EventTypeInfo.emoji(for: event.object.eventTypeCode)) .font(.subheadline) } .contentShape(Rectangle()) - .onTapGesture { onSelectEvent(event) } + .onTapGesture { onSelectEvent(event.object) } } } } diff --git a/iBurn/ListView/NearbyViewModel.swift b/iBurn/ListView/NearbyViewModel.swift index 5dee5334..dc0c2209 100644 --- a/iBurn/ListView/NearbyViewModel.swift +++ b/iBurn/ListView/NearbyViewModel.swift @@ -7,9 +7,9 @@ import PlayaDB final class NearbyViewModel: ObservableObject { // MARK: - Published - @Published var artItems: [ArtObject] = [] - @Published var campItems: [CampObject] = [] - @Published var eventItems: [EventObjectOccurrence] = [] + @Published var artItems: [ListRow] = [] + @Published var campItems: [ListRow] = [] + @Published var eventItems: [ListRow] = [] @Published var searchDistance: CLLocationDistance = 500 { didSet { restartObservations() } @@ -142,26 +142,26 @@ final class NearbyViewModel: ObservableObject { // MARK: - Sorting & Filtering - private var sortedArt: [ArtObject] { + private var sortedArt: [ListRow] { guard let loc = currentLocation else { return artItems } return artItems.sorted { a, b in - distanceTo(a.location, from: loc) < distanceTo(b.location, from: loc) + distanceTo(a.object.location, from: loc) < distanceTo(b.object.location, from: loc) } } - private var sortedCamps: [CampObject] { + private var sortedCamps: [ListRow] { guard let loc = currentLocation else { return campItems } return campItems.sorted { a, b in - distanceTo(a.location, from: loc) < distanceTo(b.location, from: loc) + distanceTo(a.object.location, from: loc) < distanceTo(b.object.location, from: loc) } } /// Events happening at the effective date, sorted by start time - private var happeningEvents: [EventObjectOccurrence] { + private var happeningEvents: [ListRow] { let date = effectiveDate return eventItems - .filter { $0.startDate <= date && $0.endDate > date } - .sorted { $0.startDate < $1.startDate } + .filter { $0.object.startDate <= date && $0.object.endDate > date } + .sorted { $0.object.startDate < $1.object.startDate } } private func distanceTo(_ location: CLLocation?, from reference: CLLocation) -> CLLocationDistance { @@ -173,9 +173,9 @@ final class NearbyViewModel: ObservableObject { func distanceString(for item: NearbyItem) -> AttributedString? { switch item { - case .art(let o): artProvider.distanceAttributedString(from: currentLocation, to: o) - case .camp(let o): campProvider.distanceAttributedString(from: currentLocation, to: o) - case .event(let o): eventProvider.distanceAttributedString(from: currentLocation, to: o) + case .art(let r): artProvider.distanceAttributedString(from: currentLocation, to: r.object) + case .camp(let r): campProvider.distanceAttributedString(from: currentLocation, to: r.object) + case .event(let r): eventProvider.distanceAttributedString(from: currentLocation, to: r.object) } } @@ -234,9 +234,9 @@ final class NearbyViewModel: ObservableObject { func toggleFavorite(_ item: NearbyItem) async { do { switch item { - case .art(let o): try await artProvider.toggleFavorite(o) - case .camp(let o): try await campProvider.toggleFavorite(o) - case .event(let o): try await eventProvider.toggleFavorite(o) + case .art(let o): try await artProvider.toggleFavorite(o.object) + case .camp(let o): try await campProvider.toggleFavorite(o.object) + case .event(let o): try await eventProvider.toggleFavorite(o.object) } } catch { print("Error toggling favorite for \(item.name): \(error)") @@ -256,11 +256,11 @@ final class NearbyViewModel: ObservableObject { for item in section.items { switch item { case .art(let o): - if let a = PlayaObjectAnnotation(art: o) { annotations.append(a) } + if let a = PlayaObjectAnnotation(art: o.object) { annotations.append(a) } case .camp(let o): - if let a = PlayaObjectAnnotation(camp: o) { annotations.append(a) } + if let a = PlayaObjectAnnotation(camp: o.object) { annotations.append(a) } case .event(let o): - if let a = PlayaObjectAnnotation(event: o) { annotations.append(a) } + if let a = PlayaObjectAnnotation(event: o.object) { annotations.append(a) } } } } @@ -330,7 +330,7 @@ final class NearbyViewModel: ObservableObject { await MainActor.run { self.eventItems = items self.markReceived("event") - self.resolveHosts(for: items) + self.resolveHosts(for: items.map(\.object)) } } } diff --git a/iBurn/ListView/ObjectListDataProvider.swift b/iBurn/ListView/ObjectListDataProvider.swift index dc62ddb8..6210bc9d 100644 --- a/iBurn/ListView/ObjectListDataProvider.swift +++ b/iBurn/ListView/ObjectListDataProvider.swift @@ -26,17 +26,18 @@ protocol ObjectListDataProvider { /// The type of filter used to query objects (ArtFilter, CampFilter, etc.) associatedtype Filter - /// Observe objects matching the filter, emitting updates via AsyncStream + /// Observe fully-inflated list rows matching the filter, emitting updates via AsyncStream. /// - /// The stream will emit: - /// - Initial set of objects matching the filter - /// - Updates whenever the underlying data changes (favorites, new imports, etc.) + /// Each `ListRow` bundles the object with its full metadata and thumbnail colors, + /// fetched in a single GRDB read transaction. /// - /// The stream completes when cancelled or when the provider is deallocated. + /// The stream will emit: + /// - Initial set of rows matching the filter + /// - Updates whenever the underlying data changes (favorites, colors, imports, etc.) /// /// - Parameter filter: The filter criteria to apply - /// - Returns: AsyncStream that yields arrays of matching objects - func observeObjects(filter: Filter) -> AsyncStream<[Object]> + /// - Returns: AsyncStream that yields arrays of fully-inflated list rows + func observeObjects(filter: Filter) -> AsyncStream<[ListRow]> /// Toggle the favorite status of an object /// @@ -46,13 +47,6 @@ protocol ObjectListDataProvider { /// - Throws: Database errors func toggleFavorite(_ object: Object) async throws - /// Check if an object is marked as a favorite - /// - /// - Parameter object: The object to check - /// - Returns: True if the object is favorited - /// - Throws: Database errors - func isFavorite(_ object: Object) async throws -> Bool - /// Get a human-readable distance string from a location to an object /// /// Returns nil if either location is unavailable or if the object has no location. diff --git a/iBurn/ListView/ObjectListViewModel.swift b/iBurn/ListView/ObjectListViewModel.swift index 16b7e1fc..a0e57f4a 100644 --- a/iBurn/ListView/ObjectListViewModel.swift +++ b/iBurn/ListView/ObjectListViewModel.swift @@ -14,7 +14,7 @@ import PlayaDB final class ObjectListViewModel: ObservableObject { // MARK: - Published - @Published var items: [Object] = [] + @Published var items: [ListRow] = [] @Published var filter: Filter { didSet { @@ -26,7 +26,6 @@ final class ObjectListViewModel = [] // MARK: - Dependencies @@ -34,7 +33,6 @@ final class ObjectListViewModel Filter - private let favoritesFilterForObservation: (Filter) -> Filter private let matchesSearch: (Object, String) -> Bool private let isDatabaseSeeded: (() async -> Bool)? @@ -42,7 +40,6 @@ final class ObjectListViewModel? private var locationTask: Task? - private var favoritesObservationTask: Task? private var loadingGateTask: Task? // MARK: - Init @@ -53,7 +50,7 @@ final class ObjectListViewModel Filter, - favoritesFilterForObservation: @escaping (Filter) -> Filter, + favoritesFilterForObservation: @escaping (Filter) -> Filter = { $0 }, matchesSearch: @escaping (Object, String) -> Bool, isDatabaseSeeded: (() async -> Bool)? = nil ) where DataProvider.Object == Object, DataProvider.Filter == Filter { @@ -61,7 +58,6 @@ final class ObjectListViewModel Bool { - favoriteIDs.contains(object.uid) + items.first(where: { $0.object.uid == object.uid })?.isFavorite ?? false } func distanceAttributedString(for object: Object) -> AttributedString? { dataProvider.distanceAttributedString(from: currentLocation, to: object) } - var filteredItems: [Object] { + var filteredItems: [ListRow] { guard !searchText.isEmpty else { return items } let q = searchText.lowercased() - return items.filter { matchesSearch($0, q) } + return items.filter { matchesSearch($0.object, q) } } // MARK: - Actions - func toggleFavorite(_ object: Object) async { - let desiredIsFavorite = !favoriteIDs.contains(object.uid) - if desiredIsFavorite { - favoriteIDs.insert(object.uid) - } else { - favoriteIDs.remove(object.uid) + func toggleFavorite(_ row: ListRow) async { + let originalRow = row + // Optimistic update + if let idx = items.firstIndex(where: { $0.object.uid == row.object.uid }) { + var updatedMeta = row.metadata + updatedMeta?.isFavorite = !row.isFavorite + items[idx] = ListRow(object: row.object, metadata: updatedMeta, thumbnailColors: row.thumbnailColors) } do { - try await dataProvider.toggleFavorite(object) + try await dataProvider.toggleFavorite(row.object) } catch { - // Revert optimistic UI if write fails. - if desiredIsFavorite { - favoriteIDs.remove(object.uid) - } else { - favoriteIDs.insert(object.uid) + // Revert on failure + if let idx = items.firstIndex(where: { $0.object.uid == originalRow.object.uid }) { + items[idx] = originalRow } - print("Error toggling favorite for \(object.name): \(error)") + print("Error toggling favorite for \(row.object.name): \(error)") } } @@ -131,18 +124,18 @@ final class ObjectListViewModel: View { let subtitle: AttributedString? let rightSubtitle: String? let isFavorite: Bool + let thumbnailColors: ThumbnailColors? let onFavoriteTap: () -> Void @ViewBuilder let actions: (RowAssetsLoader) -> Actions @@ -46,6 +47,7 @@ struct ObjectRowView: View { subtitle: AttributedString? = nil, rightSubtitle: String? = nil, isFavorite: Bool, + thumbnailColors: ThumbnailColors? = nil, onFavoriteTap: @escaping () -> Void, @ViewBuilder actions: @escaping (RowAssetsLoader) -> Actions = { _ in EmptyView() } ) { @@ -53,6 +55,7 @@ struct ObjectRowView: View { self.subtitle = subtitle self.rightSubtitle = rightSubtitle self.isFavorite = isFavorite + self.thumbnailColors = thumbnailColors self.onFavoriteTap = onFavoriteTap self.actions = actions _assets = StateObject(wrappedValue: RowAssetsLoader( @@ -61,7 +64,15 @@ struct ObjectRowView: View { } var body: some View { - let colors = assets.colors.map(ImageColors.init) ?? themeColors + let colors: ImageColors = { + if Object.supportsColorTheming, let tc = thumbnailColors { + return tc.imageColors + } + if Object.supportsColorTheming, let extracted = assets.colors { + return ImageColors(extracted) + } + return themeColors + }() HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 0) { @@ -126,18 +137,27 @@ struct ObjectRowView: View { } .padding(.vertical, 0) .listRowBackground(listRowBackground) - .onAppear { assets.startIfNeeded() } + .onAppear { + if Object.supportsColorTheming { + assets.startIfNeeded() + } + } } private var listRowBackground: some View { ZStack { themeColors.backgroundColor - if let override = assets.colors { - Color(override.backgroundColor) - .transition(.opacity) + if Object.supportsColorTheming { + if let tc = thumbnailColors { + Color(tc.backgroundColor) + .transition(.opacity) + } else if let override = assets.colors { + Color(override.backgroundColor) + .transition(.opacity) + } } } - .animation(.easeInOut(duration: 0.22), value: assets.colors != nil) + .animation(.easeInOut(duration: 0.22), value: thumbnailColors != nil || assets.colors != nil) } @ViewBuilder diff --git a/iBurn/ListView/RowAssetsLoader.swift b/iBurn/ListView/RowAssetsLoader.swift index 6b954a02..1b1ed732 100644 --- a/iBurn/ListView/RowAssetsLoader.swift +++ b/iBurn/ListView/RowAssetsLoader.swift @@ -27,7 +27,7 @@ final class RowAssetsLoader: ObservableObject { return cache }() - private static let colorsCache: NSCache = { + static let colorsCache: NSCache = { let cache = NSCache() cache.countLimit = 500 return cache diff --git a/iBurn/MutantVehicleImageDownloader.swift b/iBurn/MutantVehicleImageDownloader.swift index a64fdf5d..50d86f13 100644 --- a/iBurn/MutantVehicleImageDownloader.swift +++ b/iBurn/MutantVehicleImageDownloader.swift @@ -15,21 +15,29 @@ final class MutantVehicleImageDownloader { self.session = session } - /// Downloads images for all mutant vehicles that don't have a local cache yet. - func downloadUncachedImages() { + /// Downloads images for all mutant vehicles that don't have a valid local cache yet. + /// Returns a Task whose value is the set of newly downloaded UIDs. + @discardableResult + func downloadUncachedImages() -> Task, Never> { Task.detached(priority: .utility) { [playaDB, session] in + var newlyDownloaded: Set = [] let imageURLs: [String: URL] do { imageURLs = try await playaDB.fetchMutantVehicleImageURLs() } catch { DDLogError("MV image download: failed to fetch URLs: \(error)") - return + return newlyDownloaded } for (uid, remoteURL) in imageURLs { let fileName = "\(uid).jpg" - if BRCMediaDownloader.localMediaURL(fileName) != nil { - continue + // Validate existing file: must exist and be non-empty + if let localURL = BRCMediaDownloader.localMediaURL(fileName) { + if let attrs = try? FileManager.default.attributesOfItem(atPath: localURL.path), + let size = attrs[.size] as? Int, size > 0 { + continue + } + try? FileManager.default.removeItem(at: localURL) } do { @@ -46,11 +54,13 @@ final class MutantVehicleImageDownloader { } try FileManager.default.moveItem(at: tempURL, to: destURL) try (destURL as NSURL).setResourceValue(true, forKey: .isExcludedFromBackupKey) + newlyDownloaded.insert(uid) DDLogInfo("MV image cached: \(uid)") } catch { DDLogError("MV image download failed for \(uid): \(error)") } } + return newlyDownloaded } } } diff --git a/iBurn/PlayaDBAnnotationDataSource.swift b/iBurn/PlayaDBAnnotationDataSource.swift index a94fc01f..6642c0c2 100644 --- a/iBurn/PlayaDBAnnotationDataSource.swift +++ b/iBurn/PlayaDBAnnotationDataSource.swift @@ -58,11 +58,11 @@ final class PlayaDBAnnotationDataSource: NSObject, AnnotationDataSource { // Art if UserSettings.showArtOnMap { - let token = playaDB.observeArt(filter: ArtFilter()) { [weak self] objects in + let token = playaDB.observeArt(filter: ArtFilter()) { [weak self] rows in DispatchQueue.main.async { guard let self else { return } self.artAnnotations = embargoAllowed - ? objects.compactMap { PlayaObjectAnnotation(art: $0) } + ? rows.compactMap { PlayaObjectAnnotation(art: $0.object) } : [] self.rebuildCache() } @@ -72,11 +72,11 @@ final class PlayaDBAnnotationDataSource: NSObject, AnnotationDataSource { // Camps if UserSettings.showCampsOnMap { - let token = playaDB.observeCamps(filter: CampFilter()) { [weak self] objects in + let token = playaDB.observeCamps(filter: CampFilter()) { [weak self] rows in DispatchQueue.main.async { guard let self else { return } self.campAnnotations = embargoAllowed - ? objects.compactMap { PlayaObjectAnnotation(camp: $0) } + ? rows.compactMap { PlayaObjectAnnotation(camp: $0.object) } : [] self.rebuildCache() } @@ -91,11 +91,11 @@ final class PlayaDBAnnotationDataSource: NSObject, AnnotationDataSource { happeningNow: true, eventTypeCodes: selectedCodes ) - let token = playaDB.observeEvents(filter: filter) { [weak self] occurrences in + let token = playaDB.observeEvents(filter: filter) { [weak self] rows in DispatchQueue.main.async { guard let self else { return } self.eventAnnotations = embargoAllowed - ? occurrences.compactMap { PlayaObjectAnnotation(event: $0) } + ? rows.compactMap { PlayaObjectAnnotation(event: $0.object) } : [] self.rebuildCache() } @@ -105,11 +105,11 @@ final class PlayaDBAnnotationDataSource: NSObject, AnnotationDataSource { // Favorite art if UserSettings.showFavoritesOnMap { - let token = playaDB.observeArt(filter: ArtFilter(onlyFavorites: true)) { [weak self] objects in + let token = playaDB.observeArt(filter: ArtFilter(onlyFavorites: true)) { [weak self] rows in DispatchQueue.main.async { guard let self else { return } self.favoriteArtAnnotations = embargoAllowed - ? objects.compactMap { PlayaObjectAnnotation(art: $0) } + ? rows.compactMap { PlayaObjectAnnotation(art: $0.object) } : [] self.rebuildCache() } @@ -119,11 +119,11 @@ final class PlayaDBAnnotationDataSource: NSObject, AnnotationDataSource { // Favorite camps if UserSettings.showFavoritesOnMap { - let token = playaDB.observeCamps(filter: CampFilter(onlyFavorites: true)) { [weak self] objects in + let token = playaDB.observeCamps(filter: CampFilter(onlyFavorites: true)) { [weak self] rows in DispatchQueue.main.async { guard let self else { return } self.favoriteCampAnnotations = embargoAllowed - ? objects.compactMap { PlayaObjectAnnotation(camp: $0) } + ? rows.compactMap { PlayaObjectAnnotation(camp: $0.object) } : [] self.rebuildCache() } @@ -143,11 +143,11 @@ final class PlayaDBAnnotationDataSource: NSObject, AnnotationDataSource { eventFilter.startDate = calendar.startOfDay(for: today) eventFilter.endDate = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: today)) } - let token = playaDB.observeEvents(filter: eventFilter) { [weak self] occurrences in + let token = playaDB.observeEvents(filter: eventFilter) { [weak self] rows in DispatchQueue.main.async { guard let self else { return } self.favoriteEventAnnotations = embargoAllowed - ? occurrences.compactMap { PlayaObjectAnnotation(event: $0) } + ? rows.compactMap { PlayaObjectAnnotation(event: $0.object) } : [] self.rebuildCache() } diff --git a/iBurn/ThumbnailColors+UIKit.swift b/iBurn/ThumbnailColors+UIKit.swift new file mode 100644 index 00000000..c017216a --- /dev/null +++ b/iBurn/ThumbnailColors+UIKit.swift @@ -0,0 +1,58 @@ +import UIKit +import SwiftUI +import PlayaDB + +extension ThumbnailColors { + var backgroundColor: UIColor { + UIColor(red: bgRed, green: bgGreen, blue: bgBlue, alpha: bgAlpha) + } + + var primaryColor: UIColor { + UIColor(red: primaryRed, green: primaryGreen, blue: primaryBlue, alpha: primaryAlpha) + } + + var secondaryColor: UIColor { + UIColor(red: secondaryRed, green: secondaryGreen, blue: secondaryBlue, alpha: secondaryAlpha) + } + + var detailColor: UIColor { + UIColor(red: detailRed, green: detailGreen, blue: detailBlue, alpha: detailAlpha) + } + + var imageColors: ImageColors { + ImageColors(brcImageColors) + } + + var brcImageColors: BRCImageColors { + BRCImageColors( + backgroundColor: backgroundColor, + primaryColor: primaryColor, + secondaryColor: secondaryColor, + detailColor: detailColor + ) + } + + init(objectId: String, brcColors: BRCImageColors) { + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + + brcColors.backgroundColor.getRed(&r, green: &g, blue: &b, alpha: &a) + let bgR = Double(r), bgG = Double(g), bgB = Double(b), bgA = Double(a) + + brcColors.primaryColor.getRed(&r, green: &g, blue: &b, alpha: &a) + let pR = Double(r), pG = Double(g), pB = Double(b), pA = Double(a) + + brcColors.secondaryColor.getRed(&r, green: &g, blue: &b, alpha: &a) + let sR = Double(r), sG = Double(g), sB = Double(b), sA = Double(a) + + brcColors.detailColor.getRed(&r, green: &g, blue: &b, alpha: &a) + let dR = Double(r), dG = Double(g), dB = Double(b), dA = Double(a) + + self.init( + objectId: objectId, + bgRed: bgR, bgGreen: bgG, bgBlue: bgB, bgAlpha: bgA, + primaryRed: pR, primaryGreen: pG, primaryBlue: pB, primaryAlpha: pA, + secondaryRed: sR, secondaryGreen: sG, secondaryBlue: sB, secondaryAlpha: sA, + detailRed: dR, detailGreen: dG, detailBlue: dB, detailAlpha: dA + ) + } +} diff --git a/iBurn/ThumbnailImageDownloader.swift b/iBurn/ThumbnailImageDownloader.swift index 49d6fd4b..f3ddf477 100644 --- a/iBurn/ThumbnailImageDownloader.swift +++ b/iBurn/ThumbnailImageDownloader.swift @@ -15,9 +15,12 @@ final class ThumbnailImageDownloader { self.session = session } - /// Downloads images for all art and camp objects that don't have a local cache yet. - func downloadUncachedImages() { + /// Downloads images for all art and camp objects that don't have a valid local cache yet. + /// Returns a Task whose value is the set of newly downloaded UIDs. + @discardableResult + func downloadUncachedImages() -> Task, Never> { Task.detached(priority: .utility) { [playaDB, session] in + var newlyDownloaded: Set = [] var imageURLs: [String: URL] = [:] do { let artURLs = try await playaDB.fetchArtImageURLs() @@ -26,13 +29,18 @@ final class ThumbnailImageDownloader { imageURLs.merge(campURLs) { first, _ in first } } catch { DDLogError("Thumbnail download: failed to fetch URLs: \(error)") - return + return newlyDownloaded } for (uid, remoteURL) in imageURLs { let fileName = "\(uid).jpg" - if BRCMediaDownloader.localMediaURL(fileName) != nil { - continue + // Validate existing file: must exist and be non-empty + if let localURL = BRCMediaDownloader.localMediaURL(fileName) { + if let attrs = try? FileManager.default.attributesOfItem(atPath: localURL.path), + let size = attrs[.size] as? Int, size > 0 { + continue + } + try? FileManager.default.removeItem(at: localURL) } do { @@ -49,11 +57,13 @@ final class ThumbnailImageDownloader { } try FileManager.default.moveItem(at: tempURL, to: destURL) try (destURL as NSURL).setResourceValue(true, forKey: .isExcludedFromBackupKey) + newlyDownloaded.insert(uid) DDLogInfo("Thumbnail cached: \(uid)") } catch { DDLogError("Thumbnail download failed for \(uid): \(error)") } } + return newlyDownloaded } } } diff --git a/iBurnTests/ObjectListViewModelTests.swift b/iBurnTests/ObjectListViewModelTests.swift index 4b5869ec..bcb2dd35 100644 --- a/iBurnTests/ObjectListViewModelTests.swift +++ b/iBurnTests/ObjectListViewModelTests.swift @@ -35,9 +35,9 @@ private final class TestDataProvider: ObjectListDataProvider { private(set) var favoriteCalls: [String] = [] private(set) var favorites: Set = [] - private var continuations: [String: AsyncStream<[TestObject]>.Continuation] = [:] + var continuations: [String: AsyncStream<[ListRow]>.Continuation] = [:] - func observeObjects(filter: TestFilter) -> AsyncStream<[TestObject]> { + func observeObjects(filter: TestFilter) -> AsyncStream<[ListRow]> { lastObservedFilters.append(filter) return AsyncStream { continuation in self.continuations[filter.tag] = continuation @@ -45,7 +45,8 @@ private final class TestDataProvider: ObjectListDataProvider { } func yield(_ objects: [TestObject], tag: String = "main") { - continuations[tag]?.yield(objects) + let rows = objects.map { ListRow(object: $0, metadata: nil, thumbnailColors: nil) } + continuations[tag]?.yield(rows) } func finish(tag: String = "main") { @@ -61,10 +62,6 @@ private final class TestDataProvider: ObjectListDataProvider { } } - func isFavorite(_ object: TestObject) async throws -> Bool { - favorites.contains(object.uid) - } - func distanceAttributedString(from location: CLLocation?, to object: TestObject) -> AttributedString? { nil } @@ -156,7 +153,7 @@ final class ObjectListViewModelTests: XCTestCase { XCTAssertTrue(ok, "Expected isLoading to become false and items to be populated on first non-empty emission") } - func testFavoriteIDsComeFromFavoritesObservation() async { + func testFavoritesComeFromListRowMetadata() async { let provider = TestDataProvider() let vm = ObjectListViewModel( @@ -169,18 +166,18 @@ final class ObjectListViewModelTests: XCTestCase { f.tag = "main" return f }, - favoritesFilterForObservation: { f in - var f = f - f.tag = "favorites" - f.onlyFavorites = true - return f - }, matchesSearch: { obj, q in obj.name.lowercased().contains(q) } ) await Task.yield() - provider.yield([TestObject(name: "Fav", description: nil, uid: "fav")], tag: "favorites") - let ok = await eventually { vm.favoriteIDs == ["fav"] } + + // Yield a ListRow with isFavorite metadata + let favObj = TestObject(name: "Fav", description: nil, uid: "fav") + let meta = ObjectMetadata(objectType: "test", objectId: "fav", isFavorite: true) + let row = ListRow(object: favObj, metadata: meta, thumbnailColors: nil) + provider.continuations["main"]?.yield([row]) + + let ok = await eventually { vm.isFavorite(favObj) } XCTAssertTrue(ok) } @@ -197,12 +194,6 @@ final class ObjectListViewModelTests: XCTestCase { f.tag = "main" return f }, - favoritesFilterForObservation: { f in - var f = f - f.tag = "favorites" - f.onlyFavorites = true - return f - }, matchesSearch: { obj, q in obj.name.lowercased().contains(q) } ) @@ -215,13 +206,15 @@ final class ObjectListViewModelTests: XCTestCase { XCTAssertTrue(gotItem) XCTAssertFalse(vm.isFavorite(obj)) - await vm.toggleFavorite(obj) + let row = vm.items[0] + await vm.toggleFavorite(row) let favorited = await eventually { vm.isFavorite(obj) } XCTAssertTrue(favorited) XCTAssertEqual(provider.favoriteCalls, ["x"]) // Second toggle should flip back and also toggle provider again. - await vm.toggleFavorite(obj) + let row2 = vm.items[0] + await vm.toggleFavorite(row2) let unfavorited = await eventually { vm.isFavorite(obj) == false } XCTAssertTrue(unfavorited) XCTAssertEqual(provider.favoriteCalls, ["x", "x"])