diff --git a/Docs/2026-04-12-ai-event-summary.md b/Docs/2026-04-12-ai-event-summary.md new file mode 100644 index 00000000..2fa3dec0 --- /dev/null +++ b/Docs/2026-04-12-ai-event-summary.md @@ -0,0 +1,53 @@ +# AI Summary of Hosted Events on Detail Pages + +## Problem +Camp and art detail pages display hosted events (next event + "See all N events" link), but there's no quick summary of what the events collectively offer. Users have to tap through individual events to understand a camp/art's programming. + +## Solution +Added AI-generated event collection summaries using Apple Foundation Models (iOS 26+) with the existing workflow pipeline for guardrail/context handling. + +## Key Changes + +### New Generable Type +- **`iBurn/AISearch/AIAssistantModels.swift`**: Added `GenerableEventCollectionSummary` with single `summary` field + +### Reusable Summary Generator +- **`iBurn/AISearch/Workflows/WorkflowUtilities.swift`**: Added `generateEventCollectionSummary(events:hostName:) async -> String?` + - Wraps `withContextWindowRetry` (halves event count on context overflow) + - Inside uses `retryWithCandidateFiltering` (filters individual events that trigger guardrails) + - Formats events with name, type code display name, and truncated description (120 chars) + - Returns `nil` on complete failure for graceful degradation + - Slightly snarky tone per user preference + +### New Detail Cell Types +- **`iBurn/Detail/Models/DetailCellType.swift`**: Added `.eventSummaryLoading(hostName:)` and `.eventSummary(String, hostName:)` cases + +### Cell Rendering +- **`iBurn/Detail/Views/DetailView.swift`**: + - Added `EventSummaryHeaderView` (shared between detail cells and events list) + - Uses sparkles icon + "AI SUMMARY" header + ProgressView for loading state + - Added rendering cases in `cellContent` switch and `isCellTappable` + +### ViewModel Integration +- **`iBurn/Detail/ViewModels/DetailViewModel.swift`**: + - New state: `resolvedEventSummary: String?`, `isGeneratingEventSummary: Bool` + - `generateEventSummaryIfNeeded()` triggers after deferred data loads (Phase 3) + - `generateEventSummaryCells(hostName:)` returns loading/summary/empty cells + - Wired into `generateHostedEventCells` (camp/art), `generatePlayaEventCellTypes`, `generatePlayaEventOccurrenceCellTypes` + +### Events List Integration +- **`iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift`**: Added summary header above event list via `.task` modifier + +## Three-Phase Loading +1. **Phase 1** (existing): Metadata loads, cells render immediately +2. **Phase 2** (existing): Deferred data loads (host events, images), cells refresh +3. **Phase 3** (new): AI summary generates from resolved events, cells refresh again + +## Graceful Degradation +- Pre-iOS 26: `#if canImport(FoundationModels)` + `@available(iOS 26, *)` guards +- All events trigger guardrails: `retryWithCandidateFiltering` tries halves then individuals, returns nil if <2 safe +- Context overflow: `withContextWindowRetry` halves event count down to minimum 2 +- Complete LLM failure: Returns nil, no summary cell shown + +## Build Verification +- Build succeeds: 0 errors, 6 pre-existing warnings (none from new code) diff --git a/Docs/2026-04-12-show-tab-bar-on-push.md b/Docs/2026-04-12-show-tab-bar-on-push.md new file mode 100644 index 00000000..b96ac34b --- /dev/null +++ b/Docs/2026-04-12-show-tab-bar-on-push.md @@ -0,0 +1,28 @@ +# Show Tab Bar on All Navigation Pushes + +## Problem +Several view controllers set `hidesBottomBarWhenPushed = true`, causing the tab bar to disappear when navigating to detail screens, tracks, AI guide, recently viewed, and feature flags. With the iOS 26 Liquid Glass UI, the tab bar should remain visible throughout navigation for a consistent experience. + +## Solution +Removed all 5 instances of `hidesBottomBarWhenPushed = true` across 2 files. + +## Changes + +### `iBurn/Detail/Controllers/DetailHostingController.swift` +- Removed `self.hidesBottomBarWhenPushed = true` from init (line 67) — this affected all detail screens (art, camps, events, mutant vehicles) + +### `iBurn/MoreViewController.swift` +- Removed `tracksVC.hidesBottomBarWhenPushed = true` from `pushTracksView()` (line 343) +- Removed `hostingVC.hidesBottomBarWhenPushed = true` from `pushAIGuideView()` (line 392) +- Removed `recentVC.hidesBottomBarWhenPushed = true` from `pushRecentlyViewedView()` (line 416) +- Removed `featureFlagsVC.hidesBottomBarWhenPushed = true` from `pushFeatureFlagsView()` (line 501, DEBUG only) + +### Not changed +- `MainMapViewController.swift:183-184` — explicitly re-shows tab bar in `viewWillDisappear`, harmless safety net kept as-is + +## Verification +- Build succeeds with 0 errors, 0 warnings +- Test by navigating to detail views, tracks, recently viewed, AI guide — tab bar should remain visible + +## Branch +`show-tab-bar-on-push` from `origin/master` diff --git a/iBurn/AISearch/AIAssistantModels.swift b/iBurn/AISearch/AIAssistantModels.swift index 26b8b174..59f6915c 100644 --- a/iBurn/AISearch/AIAssistantModels.swift +++ b/iBurn/AISearch/AIAssistantModels.swift @@ -95,4 +95,18 @@ struct GenerableNearbyResponse { var highlights: [GenerableNearbyHighlight] } +@available(iOS 26, *) +@Generable +struct GenerableEventCollectionSummary { + @Guide(description: "1-2 short factual sentences about this host. Only reference provided data. No times or schedules.") + var summary: String +} + +@available(iOS 26, *) +@Generable +struct GenerableFactCheck { + @Guide(description: "Phrases from the summary that are NOT supported by the source data. Empty if everything is accurate.", .count(0...5)) + var unsupportedClaims: [String] +} + #endif diff --git a/iBurn/AISearch/AgentOrchestrator.swift b/iBurn/AISearch/AgentOrchestrator.swift index 535e698d..ba706519 100644 --- a/iBurn/AISearch/AgentOrchestrator.swift +++ b/iBurn/AISearch/AgentOrchestrator.swift @@ -24,12 +24,23 @@ final class AgentOrchestrator: @unchecked Sendable { init(playaDB: PlayaDB, locationProvider: LocationProvider) { self.playaDB = playaDB self.locationProvider = locationProvider + Self.warmUpLanguageModel() } var isAvailable: Bool { SystemLanguageModel.default.isAvailable } + /// Pre-warm the on-device language model with a trivial call so it's + /// already loaded in memory when the user opens a detail page. + private static func warmUpLanguageModel() { + guard SystemLanguageModel.default.isAvailable else { return } + Task.detached(priority: .background) { + let session = LanguageModelSession(instructions: "Reply with OK.") + _ = try? await session.respond(to: Prompt("ping")) + } + } + // MARK: - Step Execution /// Execute a single LLM step with focused tools and instructions diff --git a/iBurn/AISearch/EventSummaryCache.swift b/iBurn/AISearch/EventSummaryCache.swift new file mode 100644 index 00000000..126195cd --- /dev/null +++ b/iBurn/AISearch/EventSummaryCache.swift @@ -0,0 +1,24 @@ +// +// EventSummaryCache.swift +// iBurn +// +// In-memory cache for AI-generated event summaries, keyed by host UID. +// + +import Foundation + +/// Actor-based RAM cache for AI event summaries. +/// Thread-safe and matches the async/await calling pattern used by the workflow pipeline. +actor EventSummaryCache { + static let shared = EventSummaryCache() + + private var cache: [String: EventSummaryContent] = [:] + + func get(_ hostUID: String) -> EventSummaryContent? { + cache[hostUID] + } + + func set(_ hostUID: String, content: EventSummaryContent) { + cache[hostUID] = content + } +} diff --git a/iBurn/AISearch/Workflows/WorkflowUtilities.swift b/iBurn/AISearch/Workflows/WorkflowUtilities.swift index 37da7740..80d18b8d 100644 --- a/iBurn/AISearch/Workflows/WorkflowUtilities.swift +++ b/iBurn/AISearch/Workflows/WorkflowUtilities.swift @@ -187,6 +187,317 @@ func withContextWindowRetry( return try await attempt(minimumCount) } +// MARK: - Schedule Tip Generator (Pure Swift — No LLM) + +/// Build factual schedule tips from actual event occurrence data. +/// Groups occurrences by event name (merges duplicate EventObjects), detects recurrence, +/// sorts by day-of-week, marks expired events. Returns up to 5 ScheduleTips. +func buildScheduleTips(from events: [EventObjectOccurrence]) -> [ScheduleTip] { + guard !events.isEmpty else { return [] } + + let now = Date() + + // Formatters in BRC timezone + var brcCalendar = Calendar(identifier: .gregorian) + brcCalendar.timeZone = TimeZone.burningManTimeZone + + let dayFormatter = DateFormatter() + dayFormatter.dateFormat = "EEE" + dayFormatter.timeZone = TimeZone.burningManTimeZone + + let timeFormatter = DateFormatter() + timeFormatter.dateFormat = "h:mma" + timeFormatter.timeZone = TimeZone.burningManTimeZone + timeFormatter.amSymbol = "am" + timeFormatter.pmSymbol = "pm" + + func shortTime(_ date: Date) -> String { + let minute = brcCalendar.component(.minute, from: date) + if minute == 0 { + let hourFormatter = DateFormatter() + hourFormatter.dateFormat = "ha" + hourFormatter.timeZone = TimeZone.burningManTimeZone + hourFormatter.amSymbol = "am" + hourFormatter.pmSymbol = "pm" + return hourFormatter.string(from: date) + } + return timeFormatter.string(from: date) + } + + // Group by event NAME to merge duplicate EventObject records + struct OccurrenceInfo { + let firstEventUID: String // for navigation + let typeEmoji: String + let typeName: String + var occurrences: [(day: String, startTime: String, endTime: String, startDate: Date, endDate: Date)] + } + + var grouped: [String: OccurrenceInfo] = [:] + var order: [String] = [] + + for event in events { + let key = event.name + if grouped[key] == nil { + grouped[key] = OccurrenceInfo( + firstEventUID: event.event.uid, + typeEmoji: EventTypeInfo.emoji(for: event.eventTypeCode), + typeName: EventTypeInfo.displayName(for: event.eventTypeCode), + occurrences: [] + ) + order.append(key) + } + let day = dayFormatter.string(from: event.startDate) + let start = shortTime(event.startDate) + let end = shortTime(event.endDate) + grouped[key]?.occurrences.append((day: day, startTime: start, endTime: end, startDate: event.startDate, endDate: event.endDate)) + } + + // Deduplicate identical occurrences within each group (same day + same time range) + for key in order { + guard var info = grouped[key] else { continue } + var seen = Set() + info.occurrences = info.occurrences.filter { occ in + let fingerprint = "\(occ.day)|\(occ.startTime)|\(occ.endTime)" + return seen.insert(fingerprint).inserted + } + grouped[key] = info + } + + // Sort by earliest occurrence's day-of-week (Sun=1 → Sat=7) + let sorted = order.sorted { a, b in + let startA = grouped[a]?.occurrences.first?.startDate ?? .distantFuture + let startB = grouped[b]?.occurrences.first?.startDate ?? .distantFuture + return startA < startB + } + + // Build tips + var tips: [ScheduleTip] = [] + for name in sorted.prefix(5) { + guard let info = grouped[name] else { continue } + + let schedule: String + if info.occurrences.count == 1 { + let occ = info.occurrences[0] + schedule = "\(occ.day) \(occ.startTime)-\(occ.endTime)" + } else { + let timeRanges = Set(info.occurrences.map { "\($0.startTime)-\($0.endTime)" }) + if timeRanges.count == 1, let timeRange = timeRanges.first { + let days = info.occurrences.map(\.day).joined(separator: "/") + schedule = "\(days) \(timeRange)" + } else { + let parts = info.occurrences.map { "\($0.day) \($0.startTime)-\($0.endTime)" } + schedule = parts.joined(separator: ", ") + } + } + + let allExpired = info.occurrences.allSatisfy { $0.endDate < now } + let earliest = info.occurrences.map(\.startDate).min() ?? .distantFuture + + tips.append(ScheduleTip( + text: "\(name) (\(info.typeEmoji) \(info.typeName)) — \(schedule)", + eventUID: info.firstEventUID, + isExpired: allExpired, + earliestStart: earliest + )) + } + + return tips +} + +// MARK: - Event Collection Summary + +/// Generate schedule tips (pure Swift) and an AI overview (LLM) for a host's events. +/// Tips are always factual. The LLM overview may fail — tips alone are returned in that case. +@available(iOS 26, *) +func generateEventCollectionSummary( + events: [EventObjectOccurrence], + hostName: String, + hostUID: String, + hostDescription: String? = nil +) async -> EventSummaryContent? { + guard !events.isEmpty else { return nil } + + // Check cache first + if let cached = await EventSummaryCache.shared.get(hostUID) { + return cached + } + + // Step 1: Build factual tips from real data (instant, no LLM) + let tips = buildScheduleTips(from: events) + + // Step 2: Generate overview via LLM (may fail — that's OK) + let overview = await generateEventOverview(events: events, hostName: hostName, hostDescription: hostDescription) + + // Only return content if we have something to show + guard overview != nil || !tips.isEmpty else { return nil } + + let content = EventSummaryContent(summary: overview, tips: tips) + await EventSummaryCache.shared.set(hostUID, content: content) + return content +} + +// MARK: - Source Data Assembly + +/// Build a ground-truth string from event data + host description for fact-checking. +private func buildSourceDataString( + events: [EventObjectOccurrence], + hostName: String, + hostDescription: String? +) -> String { + var parts: [String] = [] + if let desc = hostDescription, !desc.isEmpty { + parts.append("Camp description: \(desc)") + } + parts.append("Events:") + for event in events.prefix(20) { + let type = EventTypeInfo.displayName(for: event.eventTypeCode) + let desc = event.description.map { " - \($0)" } ?? "" + parts.append(" \(event.name) [\(type)]\(desc)") + } + return parts.joined(separator: "\n") +} + +/// Extract first 1-2 sentences of host description as fallback summary. +private func extractHostSummary(hostDescription: String?) -> String? { + guard let desc = hostDescription, !desc.isEmpty else { return nil } + // Split on sentence-ending punctuation, take first 2 + var sentences: [String] = [] + var current = "" + for char in desc { + current.append(char) + if ".!?".contains(char) { + let trimmed = current.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty { sentences.append(trimmed) } + current = "" + if sentences.count >= 2 { break } + } + } + // If no sentence boundaries found, take first 150 chars + if sentences.isEmpty { + return String(desc.prefix(150)) + } + return sentences.joined(separator: " ") +} + +/// Clean up text after phrase removal: fix double spaces, dangling punctuation. +private func cleanStrippedText(_ text: String) -> String? { + var result = text + // Remove double+ spaces + while result.contains(" ") { + result = result.replacingOccurrences(of: " ", with: " ") + } + // Remove dangling ", and" / ", all" patterns left after stripping + result = result.replacingOccurrences(of: ", and ,", with: ",") + result = result.replacingOccurrences(of: ", ,", with: ",") + result = result.replacingOccurrences(of: ",,", with: ",") + result = result.trimmingCharacters(in: .whitespacesAndNewlines) + // Remove trailing comma + if result.hasSuffix(",") { result = String(result.dropLast()).trimmingCharacters(in: .whitespaces) } + // Too short after stripping = not useful + if result.count < 20 { return nil } + return result +} + +// MARK: - Validation Pipeline + +/// Validate LLM-generated overview against source data. +/// Uses withContextWindowRetry for context overflow and retries on guardrail errors. +/// Returns cleaned text with unsupported claims removed, or nil on failure. +@available(iOS 26, *) +private func validateOverview(overview: String, sourceData: String) async -> String? { + do { + let factCheck: GenerableFactCheck = try await withContextWindowRetry( + initialCount: 1, // single item, but sourceData may overflow + minimumCount: 1 + ) { _ in + let session = LanguageModelSession(instructions: """ + You are a fact-checker. Compare the summary against the source data below. \ + List any specific phrases or claims in the summary that are NOT directly \ + supported by the source data. Only flag fabricated or embellished details, \ + not reasonable inferences from the data. + """) + return try await session.respond( + to: Prompt("Summary to check:\n\(overview)\n\nSource data:\n\(sourceData)"), + generating: GenerableFactCheck.self + ).content + } + + if factCheck.unsupportedClaims.isEmpty { + return overview // Passed validation + } + + // Strip flagged phrases + var cleaned = overview + for claim in factCheck.unsupportedClaims { + cleaned = cleaned.replacingOccurrences(of: claim, with: "") + } + return cleanStrippedText(cleaned) + } catch { + print("Validation failed, discarding unverified overview: \(error)") + return nil + } +} + +// MARK: - Overview Generation (Generate → Validate → Strip) + +/// LLM-generated overview with two-pass validation. +/// Each step (generation, validation) handles its own retries via +/// withContextWindowRetry / retryWithCandidateFiltering. +@available(iOS 26, *) +private func generateEventOverview( + events: [EventObjectOccurrence], + hostName: String, + hostDescription: String? = nil +) async -> String? { + // Pass 1: Generate (retries handled by withContextWindowRetry + retryWithCandidateFiltering) + let rawOverview: String? + do { + rawOverview = try await withContextWindowRetry( + initialCount: min(events.count, 20), + minimumCount: 2 + ) { maxCount in + let slice = Array(events.prefix(maxCount)) + + let result: GenerableEventCollectionSummary = try await retryWithCandidateFiltering( + candidates: slice, + minimumCount: 2, + format: { $0.name } + ) { batch in + let text = batch.enumerated().map { idx, event in + let type = EventTypeInfo.displayName(for: event.eventTypeCode) + let desc = event.description.map { String($0.prefix(120)) } ?? "" + return "\(idx + 1). \(event.name) [\(type)]\(desc.isEmpty ? "" : " - \(desc)")" + }.joined(separator: "\n") + + var prompt = "Events hosted by \(hostName):\n\(text)" + if let hostDesc = hostDescription, !hostDesc.isEmpty { + prompt += "\n\nCamp description: \(String(hostDesc.prefix(200)))" + } + + let session = LanguageModelSession(instructions: """ + Summarize what \(hostName) offers in 1-2 short sentences. \ + Only reference details from the provided data. Do NOT infer \ + or assume anything not explicitly stated. No times or schedules. + """) + return try await session.respond( + to: Prompt(prompt), + generating: GenerableEventCollectionSummary.self + ).content + } + return result.summary + } + } catch { + print("Event overview generation failed: \(error)") + rawOverview = nil + } + + guard let rawOverview else { return nil } + + // Pass 2: Validate (retries handled by withContextWindowRetry inside validateOverview) + let sourceData = buildSourceDataString(events: events, hostName: hostName, hostDescription: hostDescription) + return await validateOverview(overview: rawOverview, sourceData: sourceData) +} + // MARK: - Note Merging /// Merge LLM-generated notes into entries by matching on lowercased name. diff --git a/iBurn/Detail/Controllers/DetailHostingController.swift b/iBurn/Detail/Controllers/DetailHostingController.swift index 45c31b81..77b068fa 100644 --- a/iBurn/Detail/Controllers/DetailHostingController.swift +++ b/iBurn/Detail/Controllers/DetailHostingController.swift @@ -64,7 +64,6 @@ class DetailHostingController: UIHostingController, DynamicViewContr super.init(rootView: DetailView(viewModel: viewModel)) self.title = titleText - self.hidesBottomBarWhenPushed = true } @MainActor required dynamic init?(coder aDecoder: NSCoder) { diff --git a/iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift b/iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift index fdc3b59a..910a8b59 100644 --- a/iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift +++ b/iBurn/Detail/Controllers/PlayaHostedEventsViewController.swift @@ -6,11 +6,12 @@ import PlayaDB /// Used for the "See all N events" tap from the PlayaDB event detail screen. @MainActor class PlayaHostedEventsViewController: UIHostingController { - init(events: [EventObjectOccurrence], hostName: String, playaDB: PlayaDB) { + init(events: [EventObjectOccurrence], hostName: String, playaDB: PlayaDB, eventSummary: EventSummaryContent? = nil) { let view = PlayaHostedEventsView( events: events, hostName: hostName, - playaDB: playaDB + playaDB: playaDB, + eventSummary: eventSummary ) super.init(rootView: view) self.title = "Events - \(hostName)" @@ -25,26 +26,40 @@ struct PlayaHostedEventsView: View { let events: [EventObjectOccurrence] let hostName: String let playaDB: PlayaDB + let eventSummary: EventSummaryContent? @Environment(\.themeColors) var themeColors @State private var favoriteIDs: Set = [] @State private var now = Date() var body: some View { - List(events, id: \.uid) { event in - ObjectRowView( - object: event, - rightSubtitle: event.timeDescription(now: now), - isFavorite: favoriteIDs.contains(event.uid), - onFavoriteTap: { - Task { await toggleFavorite(event) } + List { + // AI Summary as first scrollable row + if let eventSummary { + EventSummaryHeaderView(content: eventSummary, isLoading: false) { tip in + // Navigate to the tapped event + if let occ = events.first(where: { $0.event.uid == tip.eventUID }) { + pushDetail(for: occ) + } } - ) { _ in - Text(EventTypeInfo.emoji(for: event.eventTypeCode)) - .font(.subheadline) + .listRowBackground(themeColors.backgroundColor) + } + + ForEach(events, id: \.uid) { event in + ObjectRowView( + object: event, + rightSubtitle: event.timeDescription(now: now), + isFavorite: favoriteIDs.contains(event.uid), + onFavoriteTap: { + Task { await toggleFavorite(event) } + } + ) { _ in + Text(EventTypeInfo.emoji(for: event.eventTypeCode)) + .font(.subheadline) + } + .contentShape(Rectangle()) + .onTapGesture { pushDetail(for: event) } + .listRowBackground(themeColors.backgroundColor) } - .contentShape(Rectangle()) - .onTapGesture { pushDetail(for: event) } - .listRowBackground(themeColors.backgroundColor) } .listStyle(.plain) .task { await loadFavorites() } @@ -83,4 +98,3 @@ struct PlayaHostedEventsView: View { navController.pushViewController(detailVC, animated: true) } } - diff --git a/iBurn/Detail/Models/DetailCellType.swift b/iBurn/Detail/Models/DetailCellType.swift index 56407119..20c02689 100644 --- a/iBurn/Detail/Models/DetailCellType.swift +++ b/iBurn/Detail/Models/DetailCellType.swift @@ -39,6 +39,8 @@ enum DetailCellType { case eventRelationship(count: Int, hostName: String, onTap: (() -> Void)?) case nextHostEvent(title: String, scheduleText: String, hostName: String, onTap: (() -> Void)?) case allHostEvents(count: Int, hostName: String, onTap: (() -> Void)?) + case eventSummaryLoading(hostName: String) + case eventSummary(EventSummaryContent, hostName: String, onTipTap: ((ScheduleTip) -> Void)?) case playaAddress(String, tappable: Bool) case distance(CLLocationDistance) case travelTime(CLLocationDistance) @@ -52,6 +54,23 @@ enum DetailCellType { case viewHistory(firstViewed: Date?, lastViewed: Date?) } +// MARK: - Event Summary Content + +/// A single schedule tip built from real event occurrence data. +struct ScheduleTip: Identifiable { + let id = UUID() + let text: String // formatted display text + let eventUID: String // base event uid (for navigation) + let isExpired: Bool // all occurrences have ended + let earliestStart: Date // for day-of-week sorting +} + +/// AI-generated summary of a host's events, with Swift-generated schedule tips. +struct EventSummaryContent { + let summary: String? // LLM overview (may be nil if generation failed) + let tips: [ScheduleTip] // Swift-generated schedule tips (always factual) +} + // MARK: - Supporting Types /// Text styling options for detail cells diff --git a/iBurn/Detail/ViewModels/DetailViewModel.swift b/iBurn/Detail/ViewModels/DetailViewModel.swift index c8295895..c59fb881 100644 --- a/iBurn/Detail/ViewModels/DetailViewModel.swift +++ b/iBurn/Detail/ViewModels/DetailViewModel.swift @@ -72,6 +72,12 @@ class DetailViewModel: ObservableObject { private var resolvedHostEvents: [EventObjectOccurrence] = [] /// Resolved occurrences for an EventObject (used by .event detail to show schedule) private var resolvedEventOccurrences: [EventObjectOccurrence] = [] + /// Swift-generated schedule tips (available instantly when events load) + private var resolvedEventTips: [ScheduleTip] = [] + /// LLM-generated vibe overview (arrives async) + private var resolvedEventOverview: String? + /// Whether LLM overview generation is in progress + private var isGeneratingEventOverview = false // MARK: - Initialization @@ -396,6 +402,11 @@ class DetailViewModel: ObservableObject { if needsRefresh { self.cells = generateCells() } + + // Phase 3: Generate AI summary of hosted events + if !resolvedHostEvents.isEmpty { + await generateEventSummaryIfNeeded() + } } func toggleFavorite() async { @@ -1065,11 +1076,13 @@ class DetailViewModel: ObservableObject { let vc = PlayaHostedEventsViewController( events: self.resolvedHostEvents, hostName: hostName, - playaDB: playaDB + playaDB: playaDB, + eventSummary: self.resolvedEventTips.isEmpty && self.resolvedEventOverview == nil ? nil : EventSummaryContent(summary: self.resolvedEventOverview, tips: self.resolvedEventTips) ) self.coordinator.handle(.navigateToViewController(vc)) } )) + cellTypes.append(contentsOf: generateEventSummaryCells(hostName: hostName)) } // Schedule (from resolved occurrences) @@ -1202,11 +1215,13 @@ class DetailViewModel: ObservableObject { let vc = PlayaHostedEventsViewController( events: self.resolvedHostEvents, hostName: hostName, - playaDB: playaDB + playaDB: playaDB, + eventSummary: self.resolvedEventTips.isEmpty && self.resolvedEventOverview == nil ? nil : EventSummaryContent(summary: self.resolvedEventOverview, tips: self.resolvedEventTips) ) self.coordinator.handle(.navigateToViewController(vc)) } )) + cellTypes.append(contentsOf: generateEventSummaryCells(hostName: hostName)) } // Schedule with color-coded time @@ -1844,6 +1859,91 @@ class DetailViewModel: ObservableObject { return cells } + /// Returns AI summary cell based on current state. + /// Tips are always available when events are loaded. Overview arrives async. + private func generateEventSummaryCells(hostName: String) -> [DetailCellType] { + let hasTips = !resolvedEventTips.isEmpty + let hasOverview = resolvedEventOverview != nil + + if hasTips || hasOverview { + let content = EventSummaryContent( + summary: resolvedEventOverview, + tips: resolvedEventTips + ) + let onTipTap: ((ScheduleTip) -> Void)? = { [weak self] tip in + guard let self, let playaDB else { return } + // Find the first matching occurrence for this event + if let occ = self.resolvedHostEvents.first(where: { $0.event.uid == tip.eventUID }) { + let vc = DetailViewControllerFactory.create(with: occ, playaDB: playaDB) + self.coordinator.handle(.navigateToViewController(vc)) + } + } + return [.eventSummary(content, hostName: hostName, onTipTap: onTipTap)] + } else if isGeneratingEventOverview { + return [.eventSummaryLoading(hostName: hostName)] + } + return [] + } + + /// Compute schedule tips (sync) and kick off LLM overview (async). + private func generateEventSummaryIfNeeded() async { + guard !resolvedHostEvents.isEmpty, + resolvedEventTips.isEmpty, + !isGeneratingEventOverview else { return } + + let hostName: String + let hostUID: String + switch subject { + case .art(let art): hostName = art.name; hostUID = art.uid + case .camp(let camp): hostName = camp.name; hostUID = camp.uid + case .event(let event): + hostName = resolvedHostName ?? "this host" + hostUID = event.hostedByCamp ?? event.locatedAtArt ?? event.uid + case .eventOccurrence(let occ): + hostName = resolvedHostName ?? "this host" + hostUID = occ.hostedByCamp ?? occ.locatedAtArt ?? occ.event.uid + default: return + } + + // Check cache first — show immediately without loading spinner + if let cached = await EventSummaryCache.shared.get(hostUID) { + resolvedEventTips = cached.tips + resolvedEventOverview = cached.summary + self.cells = generateCells() + return + } + + // Step 1: Compute tips instantly from real data (pure Swift) + #if canImport(FoundationModels) + if #available(iOS 26, *) { + resolvedEventTips = buildScheduleTips(from: resolvedHostEvents) + } + #endif + self.cells = generateCells() // Show tips immediately + + // Step 2: Generate LLM overview asynchronously + #if canImport(FoundationModels) + if #available(iOS 26, *) { + isGeneratingEventOverview = true + + let content = await generateEventCollectionSummary( + events: resolvedHostEvents, + hostName: hostName, + hostUID: hostUID, + hostDescription: resolvedHostDescription + ) + + isGeneratingEventOverview = false + if let content { + resolvedEventOverview = content.summary + // Update tips from cache if they differ (shouldn't, but be safe) + if !content.tips.isEmpty { resolvedEventTips = content.tips } + } + self.cells = generateCells() + } + #endif + } + /// Generate hosted event cells (next event + all events) for a camp/art detail screen. private func generateHostedEventCells(hostName: String) -> [DetailCellType] { guard let playaDB, !resolvedHostEvents.isEmpty else { return [] } @@ -1877,12 +1977,16 @@ class DetailViewModel: ObservableObject { let vc = PlayaHostedEventsViewController( events: self.resolvedHostEvents, hostName: hostName, - playaDB: playaDB + playaDB: playaDB, + eventSummary: self.resolvedEventTips.isEmpty && self.resolvedEventOverview == nil ? nil : EventSummaryContent(summary: self.resolvedEventOverview, tips: self.resolvedEventTips) ) self.coordinator.handle(.navigateToViewController(vc)) } )) + // AI summary of hosted events + cells.append(contentsOf: generateEventSummaryCells(hostName: hostName)) + return cells } diff --git a/iBurn/Detail/Views/DetailView.swift b/iBurn/Detail/Views/DetailView.swift index 4f5e1fb6..57d311e5 100644 --- a/iBurn/Detail/Views/DetailView.swift +++ b/iBurn/Detail/Views/DetailView.swift @@ -213,7 +213,13 @@ struct DetailCellView: View { case .allHostEvents(let count, let hostName, _): DetailAllHostEventsCell(count: count, hostName: hostName) - + + case .eventSummaryLoading: + EventSummaryHeaderView(content: nil, isLoading: true) + + case .eventSummary(let content, _, let onTipTap): + EventSummaryHeaderView(content: content, isLoading: false, onTipTap: onTipTap) + case .schedule(let attributedString): DetailScheduleCell(attributedString: attributedString) @@ -271,7 +277,7 @@ struct DetailCellView: View { return onTap != nil case .playaAddress(_, let tappable): return tappable - case .text, .distance, .travelTime, .schedule, .date, .landmark, .eventType: + case .text, .distance, .travelTime, .schedule, .date, .landmark, .eventType, .eventSummaryLoading, .eventSummary(_, _, _): return false case .image: return true @@ -705,6 +711,67 @@ struct DetailAllHostEventsCell: View { } } +/// Shared view for AI event summary — used by both DetailView cells and PlayaHostedEventsView. +struct EventSummaryHeaderView: View { + let content: EventSummaryContent? + let isLoading: Bool + var onTipTap: ((ScheduleTip) -> Void)? + @Environment(\.themeColors) var themeColors + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label("AI SLOP SUMMARY", systemImage: "sparkles") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(themeColors.detailColor) + .textCase(.uppercase) + + if isLoading { + HStack(spacing: 8) { + ProgressView() + .scaleEffect(0.7) + Text("Summarizing events...") + .font(.caption) + .foregroundColor(themeColors.secondaryColor) + } + } else if let content { + if let summary = content.summary { + Text(summary) + .font(.subheadline) + .foregroundColor(themeColors.secondaryColor) + .fixedSize(horizontal: false, vertical: true) + } + + if !content.tips.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(content.tips) { tip in + let expired = tip.isExpired && !YearSettings.isEventOver + if onTipTap != nil { + Button { + onTipTap?(tip) + } label: { + Text("• \(tip.text)") + .font(.caption) + .foregroundColor(expired ? themeColors.secondaryColor : themeColors.detailColor) + .strikethrough(expired) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + Text("• \(tip.text)") + .font(.caption) + .foregroundColor(expired ? themeColors.secondaryColor : themeColors.detailColor) + .strikethrough(expired) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + } + } + } +} + struct DetailLandmarkCell: View { let landmark: String @Environment(\.themeColors) var themeColors diff --git a/iBurn/MoreViewController.swift b/iBurn/MoreViewController.swift index c30f472f..ac261f04 100644 --- a/iBurn/MoreViewController.swift +++ b/iBurn/MoreViewController.swift @@ -340,7 +340,6 @@ class MoreViewController: UITableViewController, SKStoreProductViewControllerDel func pushTracksView() { let tracksVC = TracksViewController() - tracksVC.hidesBottomBarWhenPushed = true self.navigationController?.pushViewController(tracksVC, animated: true) } @@ -389,7 +388,6 @@ class MoreViewController: UITableViewController, SKStoreProductViewControllerDel } let hostingVC = UIHostingController(rootView: view) hostingVC.title = "AI Guide" - hostingVC.hidesBottomBarWhenPushed = true navigationController?.pushViewController(hostingVC, animated: true) } #endif @@ -413,7 +411,6 @@ class MoreViewController: UITableViewController, SKStoreProductViewControllerDel func pushRecentlyViewedView() { let recentVC = RecentlyViewedHostingController(dependencies: BRCAppDelegate.shared.dependencies) - recentVC.hidesBottomBarWhenPushed = true navigationController?.pushViewController(recentVC, animated: true) } @@ -498,7 +495,6 @@ class MoreViewController: UITableViewController, SKStoreProductViewControllerDel #if DEBUG func pushFeatureFlagsView() { let featureFlagsVC = FeatureFlagsHostingController() - featureFlagsVC.hidesBottomBarWhenPushed = true navigationController?.pushViewController(featureFlagsVC, animated: true) } #endif