diff --git a/hackertracker/Utils/Filters.swift b/hackertracker/Utils/Filters.swift index 3e56dde..91dbd49 100644 --- a/hackertracker/Utils/Filters.swift +++ b/hackertracker/Utils/Filters.swift @@ -7,10 +7,81 @@ import Foundation +/// Shared persistence helpers used by every filter store below. +private enum FilterStorePersistence { + static func loadIntSet(forKey key: String) -> Set? { + guard let data = UserDefaults.standard.data(forKey: key) else { return nil } + return try? JSONDecoder().decode(Set.self, from: data) + } + + static func loadStringSet(forKey key: String) -> Set? { + guard let data = UserDefaults.standard.data(forKey: key) else { return nil } + return try? JSONDecoder().decode(Set.self, from: data) + } + + static func save(_ value: T, forKey key: String) { + if let data = try? JSONEncoder().encode(value) { + UserDefaults.standard.set(data, forKey: key) + } + } +} + class Filters: ObservableObject { - @Published var filters: Set - + private static let userDefaultsKey = "filtersStore.schedule.v1" + @Published var filters: Set { + didSet { FilterStorePersistence.save(filters, forKey: Self.userDefaultsKey) } + } + init(filters: Set) { - self.filters = filters + // Persisted value (if any) wins over the caller's default. + // Survives cold launch so users don't have to re-pick chips + // every time they relaunch the app. + self.filters = FilterStorePersistence.loadIntSet(forKey: Self.userDefaultsKey) ?? filters } } + +/// Independent filter set for the Speakers list. Distinct class from +/// `Filters` so both can sit in the SwiftUI environment without type +/// collision — `Filters` continues to drive Schedule + All Content +/// while `SpeakerFiltersStore` is read only by SpeakersView and the +/// speaker filter sheet. +final class SpeakerFiltersStore: ObservableObject { + private static let userDefaultsKey = "filtersStore.speakers.v1" + @Published var filters: Set { + didSet { FilterStorePersistence.save(filters, forKey: Self.userDefaultsKey) } + } + + init(filters: Set = []) { + self.filters = FilterStorePersistence.loadIntSet(forKey: Self.userDefaultsKey) ?? filters + } +} + +/// Independent filter set for the Merch (Products) list. Holds the +/// selected size labels rather than tag ids; otherwise identical to +/// the other stores. Hoisted out of ProductsView's @State so the +/// selection survives tab switches in addition to cold launches. +final class MerchFiltersStore: ObservableObject { + private static let userDefaultsKey = "filtersStore.merch.v1" + @Published var sizes: Set { + didSet { FilterStorePersistence.save(sizes, forKey: Self.userDefaultsKey) } + } + + init(sizes: Set = []) { + self.sizes = FilterStorePersistence.loadStringSet(forKey: Self.userDefaultsKey) ?? sizes + } +} + +/// Configuration knobs shared between `SpeakerRow` (chip strip) and +/// `SpeakersView` (filter sheet + filter pipeline) so all three stay +/// consistent. +enum SpeakerListConfig { + /// Tagtype labels intentionally hidden from the speakers list. + /// These dimensions live on events (talks have a skill level and a + /// modality) but don't read as useful speaker metadata — a + /// speaker isn't "Beginner" or "Hybrid", their *talk* is. Drop + /// them from both the chip rollup and the filter sheet so users + /// see only the categorical/organizational signals. + /// "Tool" is included on the same logic: events tagged "Tool" are + /// tooling releases / demos, but the speaker isn't a tool. + static let excludedTagTypeLabels: Set = ["Skill Level", "Modality", "Tool"] +} diff --git a/hackertracker/Utils/NotificationUtility.swift b/hackertracker/Utils/NotificationUtility.swift index 69034d7..8049b5e 100644 --- a/hackertracker/Utils/NotificationUtility.swift +++ b/hackertracker/Utils/NotificationUtility.swift @@ -6,7 +6,11 @@ // import Foundation -import UserNotifications +// Swift 6 strict concurrency: UNNotificationRequest isn't yet annotated +// Sendable in UserNotifications. Treat the framework's Sendable +// diagnostics as warnings so the existing call sites (notably the +// addNotification closure capture below) keep compiling clean. +@preconcurrency import UserNotifications enum NotificationUtility { /// Phase 1 fix: previously a DispatchSemaphore-blocking accessor that could deadlock diff --git a/hackertracker/Utils/Searchable.swift b/hackertracker/Utils/Searchable.swift index 35aabe7..59a5192 100644 --- a/hackertracker/Utils/Searchable.swift +++ b/hackertracker/Utils/Searchable.swift @@ -60,6 +60,27 @@ extension [Speaker] { guard !text.isEmpty else { return self } return filter { _searchMatches($0.name, needle: text) || _searchMatches($0.description, needle: text) } } + + /// Speaker search with the speaker's event titles as additional + /// match surface. Passing an `eventsById` dict lets the search + /// match against talk titles too — useful when the user is + /// looking up "who's giving the BadgeLife panel" rather than the + /// speaker by name. Falls back gracefully when an event id isn't + /// in the dict (cold load), in which case that event just + /// doesn't contribute to the match for this speaker. + func search(text: String, eventsById: [Int: Event]) -> Self { + guard !text.isEmpty else { return self } + return filter { speaker in + if _searchMatches(speaker.name, needle: text) + || _searchMatches(speaker.description, needle: text) { + return true + } + return speaker.eventIds.contains { id in + guard let title = eventsById[id]?.title else { return false } + return _searchMatches(title, needle: text) + } + } + } } extension [Content] { diff --git a/hackertracker/Utils/TalkSummaryCache.swift b/hackertracker/Utils/TalkSummaryCache.swift index b5f4b25..06676b2 100644 --- a/hackertracker/Utils/TalkSummaryCache.swift +++ b/hackertracker/Utils/TalkSummaryCache.swift @@ -23,16 +23,38 @@ import FoundationModels #endif /// Common shape shared by anything we know how to summarize. Lets -/// the cache key by stable Int id + accept either a `Content` -/// (All Content / Talks tab) or an `Event` (Schedule tab) without +/// the cache accept any of: `Content` (All Content / Talks tab), +/// `Event` (Schedule tab), or `Speaker` (Speakers tab) without /// duplicating method bodies. protocol SummarizableTalk { var id: Int { get } var description: String { get } + /// Stable key used by the persistence layer. Default impl returns + /// the integer id as-is for talks; types that share id space with + /// talks (e.g. `Speaker` may collide with `Content`/`Event` ids) + /// override to namespace. + var summaryCacheKey: String { get } + /// Selects the prompt template. The talk-bio prompt isn't the same + /// thing as the speaker-bio prompt — we want both targeted. + var summaryKind: SummaryKind { get } +} + +extension SummarizableTalk { + var summaryCacheKey: String { String(id) } + var summaryKind: SummaryKind { .talk } +} + +enum SummaryKind { + case talk // Content + Event (conference talks) + case speaker // Bio of a person presenting } extension Content: SummarizableTalk {} extension Event: SummarizableTalk {} +extension Speaker: SummarizableTalk { + var summaryCacheKey: String { "speaker:\(id)" } + var summaryKind: SummaryKind { .speaker } +} @Observable @MainActor @@ -62,12 +84,12 @@ final class TalkSummaryCache { let createdAt: Date } - private var memory: [Int: Entry] = [:] - @ObservationIgnored private var inflight: [Int: Task] = [:] - /// FIFO queue of items waiting for a slot. We store id + the raw - /// description string rather than the SummarizableTalk itself so + private var memory: [String: Entry] = [:] + @ObservationIgnored private var inflight: [String: Task] = [:] + /// FIFO queue of items waiting for a slot. We store key + + /// description + kind rather than the SummarizableTalk itself so /// the queue isn't tied to either model type's lifecycle. - @ObservationIgnored private var pending: [(id: Int, description: String)] = [] + @ObservationIgnored private var pending: [(key: String, description: String, kind: SummaryKind)] = [] private init() { load() @@ -81,13 +103,14 @@ final class TalkSummaryCache { /// changed since we last summarized (the stale entry is dropped /// to force a re-warm). func summary(for item: any SummarizableTalk) -> String? { - guard let entry = memory[item.id] else { return nil } + let key = item.summaryCacheKey + guard let entry = memory[key] else { return nil } let currentHash = Self.stableHash(item.description) if entry.descriptionHash == currentHash { return entry.summary } // Description changed -> stale. Drop and let the next warm refill. - memory.removeValue(forKey: item.id) + memory.removeValue(forKey: key) persist() return nil } @@ -109,23 +132,24 @@ final class TalkSummaryCache { let trimmed = item.description.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.count >= minDescriptionChars else { return } guard summary(for: item) == nil else { return } - guard inflight[item.id] == nil else { return } + let key = item.summaryCacheKey + guard inflight[key] == nil else { return } if inflight.count >= maxConcurrent { - if !pending.contains(where: { $0.id == item.id }) { - pending.append((item.id, item.description)) + if !pending.contains(where: { $0.key == key }) { + pending.append((key, item.description, item.summaryKind)) } return } - spawn(id: item.id, description: item.description) + spawn(key: key, description: item.description, kind: item.summaryKind) } // MARK: - Generation - private func spawn(id: Int, description: String) { + private func spawn(key: String, description: String, kind: SummaryKind) { #if canImport(FoundationModels) if #available(iOS 26.0, *) { - spawnReal(id: id, description: description) + spawnReal(key: key, description: description, kind: kind) return } #endif @@ -136,12 +160,12 @@ final class TalkSummaryCache { #if canImport(FoundationModels) @available(iOS 26.0, *) - private func spawnReal(id: Int, description: String) { - let prompt = Self.prompt(for: description) + private func spawnReal(key: String, description: String, kind: SummaryKind) { + let prompt = Self.prompt(for: description, kind: kind) let task = Task { @MainActor [weak self] in defer { - self?.inflight.removeValue(forKey: id) + self?.inflight.removeValue(forKey: key) self?.pump() } do { @@ -156,15 +180,15 @@ final class TalkSummaryCache { summary: summary, createdAt: Date() ) - self?.memory[id] = entry + self?.memory[key] = entry self?.prune() self?.persist() - Log.app.debug("AI summary OK for \(id, privacy: .public): \(summary, privacy: .public)") + Log.app.debug("AI summary OK for \(key, privacy: .public): \(summary, privacy: .public)") } catch { - Log.app.debug("AI summary failed for \(id, privacy: .public): \(error.localizedDescription, privacy: .public)") + Log.app.debug("AI summary failed for \(key, privacy: .public): \(error.localizedDescription, privacy: .public)") } } - inflight[id] = task + inflight[key] = task } #endif @@ -174,32 +198,45 @@ final class TalkSummaryCache { while inflight.count < maxConcurrent, !pending.isEmpty { let next = pending.removeFirst() // Re-check we don't already have a fresh entry by hashing. - // We can't call summary(for:) here without a SummarizableTalk, - // but checking the stored hash directly is equivalent. let h = Self.stableHash(next.description) - if let e = memory[next.id], e.descriptionHash == h { continue } - guard inflight[next.id] == nil else { continue } - spawn(id: next.id, description: next.description) + if let e = memory[next.key], e.descriptionHash == h { continue } + guard inflight[next.key] == nil else { continue } + spawn(key: next.key, description: next.description, kind: next.kind) } } // MARK: - Prompt + cleanup - /// Conference-flavored single-sentence summary. We bias toward - /// "what will the audience actually learn" because organizers - /// often write descriptions in marketing voice that fails to - /// answer that question on its own. - private static func prompt(for description: String) -> String { - """ - Summarize this conference talk description in ONE sentence of - about 15 words. Focus on what the audience will learn or what - is demonstrated. Be concrete and concise. No hype, no - marketing language, no leading phrases like "this talk" or - "the speaker"—just the content. Do not add quotation marks. - - Description: - \(description) - """ + /// Single-sentence summary, prompt tailored per kind. Talk prompts + /// bias toward "what will the audience learn"; speaker prompts bias + /// toward "what is this person known for" so the result reads as a + /// substitute for a missing job title rather than a TL;DR of their bio. + private static func prompt(for description: String, kind: SummaryKind) -> String { + switch kind { + case .talk: + return """ + Summarize this conference talk description in ONE sentence of + about 15 words. Focus on what the audience will learn or what + is demonstrated. Be concrete and concise. No hype, no + marketing language, no leading phrases like "this talk" or + "the speaker"—just the content. Do not add quotation marks. + + Description: + \(description) + """ + case .speaker: + return """ + Summarize this conference speaker bio in ONE short fragment of + about 10 words — like a job title or professional descriptor + that reads naturally below the speaker's name. Capture what + they do or are known for. No hype, no leading phrases like + "this speaker" or "the bio"—just the substance. Do not add + quotation marks. Do not end with a period. + + Bio: + \(description) + """ + } } /// Strip wrapping quotes, leading "Summary:" markers, trailing @@ -225,14 +262,24 @@ final class TalkSummaryCache { private func load() { guard let data = UserDefaults.standard.data(forKey: Self.defaultsKey) else { return } - do { - let decoded = try JSONDecoder().decode([Int: Entry].self, from: data) + // Try the current shape first; fall back to the legacy [Int: Entry] + // shape from before the Speaker namespacing landed. Legacy entries + // are folded into the new String-keyed map under their bare id — + // matches the default summaryCacheKey for Content/Event so existing + // talk summaries survive the upgrade. + if let decoded = try? JSONDecoder().decode([String: Entry].self, from: data) { self.memory = decoded Log.app.debug("TalkSummaryCache loaded \(decoded.count, privacy: .public) summaries") - } catch { - // Corrupt or schema-changed payload — drop it. - UserDefaults.standard.removeObject(forKey: Self.defaultsKey) + return + } + if let legacy = try? JSONDecoder().decode([Int: Entry].self, from: data) { + self.memory = Dictionary(uniqueKeysWithValues: legacy.map { (String($0.key), $0.value) }) + Log.app.debug("TalkSummaryCache migrated \(self.memory.count, privacy: .public) legacy entries") + persist() + return } + // Corrupt payload — drop it. + UserDefaults.standard.removeObject(forKey: Self.defaultsKey) } private func persist() { @@ -249,8 +296,8 @@ final class TalkSummaryCache { // Sort by createdAt asc, drop the oldest until we're back at cap. let sorted = memory.sorted { $0.value.createdAt < $1.value.createdAt } let toDrop = sorted.count - maxEntries - for (id, _) in sorted.prefix(toDrop) { - memory.removeValue(forKey: id) + for (key, _) in sorted.prefix(toDrop) { + memory.removeValue(forKey: key) } } diff --git a/hackertracker/Utils/Theme.swift b/hackertracker/Utils/Theme.swift index e69f10b..6e95978 100644 --- a/hackertracker/Utils/Theme.swift +++ b/hackertracker/Utils/Theme.swift @@ -60,6 +60,12 @@ enum ThemeColors { ? UIColor(red: 38/255, green: 38/255, blue: 42/255, alpha: 1) : UIColor(red: 235/255, green: 235/255, blue: 240/255, alpha: 1) }) + + /// Mid-tone text color used by row "secondary" content — speaker + /// subtitle, AI summary line, event-titles line, chip labels. + /// Brighter than `.gray` (~50%) but still clearly subordinate to + /// the row title. Built off `.primary` so it adapts to theme. + static let muted = Color.primary.opacity(0.75) } // MARK: - AppTheme infrastructure @@ -480,6 +486,7 @@ enum ThemeRegistry { /// property does fire those notifications; we sync to UserDefaults /// explicitly in setTheme() so the choice survives launches. @Observable +@MainActor final class ThemeManager { private static let userDefaultsKey = "themeID" @@ -510,7 +517,9 @@ final class ThemeManager { } /// Instance-level call site; just delegates to the static version - /// using the currently-stored theme id. + /// using the currently-stored theme id. `@MainActor` because the + /// static delegate touches UIKit appearance proxies. + @MainActor private func applyNavBarAppearance() { ThemeManager.applyNavBarAppearance(for: storedID) } @@ -526,6 +535,11 @@ final class ThemeManager { /// app's initial UINavigationBars get instantiated against the /// default appearance and only refresh on push/pop. Reads the /// persisted theme id directly from UserDefaults. + /// + /// `@MainActor` because every UIKit appearance API touched here is + /// main-actor-isolated under Swift 6. The two call sites (init, + /// `hackertrackerApp.init()`) already run on the main actor. + @MainActor static func applyNavBarAppearance(for themeID: String? = nil) { let resolvedID = themeID ?? UserDefaults.standard.string(forKey: ThemeManager.userDefaultsKey) diff --git a/hackertracker/ViewModels/InfoViewModel.swift b/hackertracker/ViewModels/InfoViewModel.swift index e3d0ad5..3dc8f40 100644 --- a/hackertracker/ViewModels/InfoViewModel.swift +++ b/hackertracker/ViewModels/InfoViewModel.swift @@ -71,7 +71,9 @@ final class InfoViewModel { } } /// Phase 2: index rebuilt whenever `events` changes. - @ObservationIgnored private var eventsById: [Int: Event] = [:] + /// Exposed as `private(set)` so views needing O(1) lookups + /// (e.g. SpeakerRow's tag rollup) don't have to do O(n) scans. + @ObservationIgnored private(set) var eventsById: [Int: Event] = [:] /// Phase 2: cached per-event conflict result for a given bookmark set. /// Cleared when events change or bookmarks change. @ObservationIgnored private var conflictCache: [Int: Bool] = [:] diff --git a/hackertracker/Views/ContentCellView.swift b/hackertracker/Views/ContentCellView.swift index 141d404..6c4c27f 100644 --- a/hackertracker/Views/ContentCellView.swift +++ b/hackertracker/Views/ContentCellView.swift @@ -87,11 +87,11 @@ struct ContentCell: View { HStack(alignment: .top, spacing: 4) { Image(systemName: "sparkles") .font(themeManager.captionFont) - .foregroundStyle(.secondary) + .foregroundColor(ThemeColors.muted) .padding(.top, 2) Text(summary) .font(themeManager.captionFont) - .foregroundStyle(.secondary) + .foregroundColor(ThemeColors.muted) .lineLimit(2) .multilineTextAlignment(.leading) } diff --git a/hackertracker/Views/ContentView.swift b/hackertracker/Views/ContentView.swift index 26286ab..308e3c8 100644 --- a/hackertracker/Views/ContentView.swift +++ b/hackertracker/Views/ContentView.swift @@ -50,6 +50,13 @@ struct ContentView: View { @StateObject private var toCurrent = ToCurrent() @StateObject private var toNext = ToNext() @StateObject var filters = Filters(filters:[]) + /// Independent filter set for the Speakers list — selections here + /// don't bleed into Schedule / All Content. + @StateObject var speakerFilters = SpeakerFiltersStore() + /// Independent filter set for the Merch list. Holds the user's + /// selected sizes. Hoisted from ProductsView's @State so the + /// selection survives tab switches (and now cold launches too). + @StateObject var merchFilters = MerchFiltersStore() @State private var tabSelection = 1 // @State private var tappedMainTwice = false // @State private var tappedScheduleTwice = false @@ -157,6 +164,8 @@ struct ContentView: View { .environmentObject(toCurrent) .environmentObject(toNext) .environmentObject(filters) + .environmentObject(speakerFilters) + .environmentObject(merchFilters) // Themes: set the default body font for the entire tab // tree. Any Text() that doesn't explicitly call .font() // inherits this — so card labels, schedule rows, settings @@ -182,6 +191,8 @@ struct ContentView: View { .environmentObject(theme) .environment(themeManager) .environmentObject(filters) + .environmentObject(speakerFilters) + .environmentObject(merchFilters) } else { _04View(message: "Loading", show404: false).preferredColorScheme(theme.colorScheme) .preferredColorScheme(theme.colorScheme) @@ -191,6 +202,8 @@ struct ContentView: View { .environmentObject(theme) .environment(themeManager) .environmentObject(filters) + .environmentObject(speakerFilters) + .environmentObject(merchFilters) .task { Log.app.debug("ContentView selected=\(selected.code, privacy: .public) stored=\(conferenceCode, privacy: .public)") if selected.code != conferenceCode { diff --git a/hackertracker/Views/EventCellView.swift b/hackertracker/Views/EventCellView.swift index 7e4f917..1318bdd 100644 --- a/hackertracker/Views/EventCellView.swift +++ b/hackertracker/Views/EventCellView.swift @@ -91,11 +91,11 @@ struct EventCell: View { HStack(alignment: .top, spacing: 4) { Image(systemName: "sparkles") .font(themeManager.captionFont) - .foregroundStyle(.secondary) + .foregroundColor(ThemeColors.muted) .padding(.top, 2) Text(summary) .font(themeManager.captionFont) - .foregroundStyle(.secondary) + .foregroundColor(ThemeColors.muted) .lineLimit(2) .multilineTextAlignment(.leading) } @@ -202,6 +202,9 @@ struct EventCell: View { struct ShowEventCellTags: View { var tagIds: [Int] + /// Legacy parameter kept for source compatibility with existing + /// call sites. The horizontal-scroll layout below ignores it; + /// chips size to their natural label width now. var minWidth: CGFloat = 100 @Environment(ThemeManager.self) private var themeManager /// When true, prepend a synthetic "Custom Event" chip ahead of @@ -218,31 +221,48 @@ struct ShowEventCellTags: View { return .purple } + /// Single-line, naturally-sized chips. A horizontal ScrollView + /// wraps the HStack so overflow scrolls instead of wrapping into + /// a 2- or 3-column grid (which made longer labels like "Hands-on + /// Workshop" wrap awkwardly and split rows of unequal height). + /// `lineLimit(1)` + `.fixedSize` keep each chip tight to its + /// label so the row reads as one rail. var body: some View { - LazyVGrid(columns: [GridItem(.adaptive(minimum: minWidth))], alignment: .leading, spacing: 1) { - if customEvent { - HStack { - Circle().foregroundColor(customChipColor) - .frame(width: 8, height: 8, alignment: .center) - Text("Custom Event").font(themeManager.captionFont) - .multilineTextAlignment(.leading) - .frame(alignment: .leading) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + if customEvent { + chip(color: customChipColor, label: "Custom Event") } - } - ForEach(tagIds, id: \.self) { tagId in - if let tag = viewModel.tagsById[tagId] { - VStack { - HStack { - Circle().foregroundColor(Color(UIColor(hex: tag.colorBackground ?? "#2c8f07") ?? .purple)) - .frame(width: 8, height: 8, alignment: .center) - Text(tag.label).font(themeManager.captionFont) - .multilineTextAlignment(.leading) - .frame(alignment: .leading) - } + ForEach(tagIds, id: \.self) { tagId in + if let tag = viewModel.tagsById[tagId] { + chip( + color: Color(UIColor(hex: tag.colorBackground ?? "#2c8f07") ?? .purple), + label: tag.label + ) } } } } + // ScrollView reports its content size as the smaller of its + // intrinsic content width or the parent's available width. + // .scrollIndicators(.hidden) + .scrollClipDisabled() let + // chips visually bleed up against the leading edge without + // a dim scroll indicator. + .scrollIndicators(.hidden) .frame(maxWidth: .infinity, alignment: .leading) } + + @ViewBuilder + private func chip(color: Color, label: String) -> some View { + HStack(spacing: 4) { + Circle() + .foregroundColor(color) + .frame(width: 8, height: 8) + Text(label) + .font(themeManager.captionFont) + .foregroundColor(ThemeColors.muted) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + } } diff --git a/hackertracker/Views/ProductsView.swift b/hackertracker/Views/ProductsView.swift index 5897320..b4b43ff 100644 --- a/hackertracker/Views/ProductsView.swift +++ b/hackertracker/Views/ProductsView.swift @@ -18,11 +18,12 @@ struct ProductsView: View { /// than on every keystroke. @State private var debouncedSearch = "" @State private var showFilters = false - /// Local merch-size filter state. Variant titles carry the size - /// label (e.g. "XS"); kept here rather than in the shared Filters - /// env object so it does not collide with the Schedule tag filters. - @State private var selectedSizes: Set = [] - @AppStorage("filterMatchMode") private var filterMatchModeRaw: String = FilterMatchMode.defaultRaw + /// Merch-size selection lives in `MerchFiltersStore` (injected via + /// environment from ContentView). Hoisting out of @State lets the + /// selection survive tab switches; the store also persists it + /// across cold launches via UserDefaults. + @EnvironmentObject private var merchFilters: MerchFiltersStore + @AppStorage("filterMatchModeMerch") private var filterMatchModeRaw: String = FilterMatchMode.defaultRaw private var filterMatchMode: FilterMatchMode { FilterMatchMode(rawOrDefault: filterMatchModeRaw) } @@ -77,7 +78,7 @@ struct ProductsView: View { // whose title matches a selected size. Out-of-stock SKUs // do not satisfy the filter so the grid only shows items // the user could actually buy in that size. - guard !selectedSizes.isEmpty else { return true } + guard !merchFilters.sizes.isEmpty else { return true } // In-stock variant titles available for this product. let inStockTitles = Set( product.variants @@ -87,12 +88,12 @@ struct ProductsView: View { switch filterMatchMode { case .any: // Any of the selected sizes is available. - return !inStockTitles.isDisjoint(with: selectedSizes) + return !inStockTitles.isDisjoint(with: merchFilters.sizes) case .all: // Every selected size is available — for users // who need a specific bundle of sizes (e.g. both // M and L for two recipients). - return selectedSizes.isSubset(of: inStockTitles) + return merchFilters.sizes.isSubset(of: inStockTitles) } } } @@ -214,7 +215,7 @@ struct ProductsView: View { Button { showFilters.toggle() } label: { - Image(systemName: selectedSizes.isEmpty + Image(systemName: merchFilters.sizes.isEmpty ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill") .font(themeManager.title2Font) @@ -222,7 +223,7 @@ struct ProductsView: View { .background(.regularMaterial, in: Circle()) } .tint(.primary) - .accessibilityLabel(selectedSizes.isEmpty ? "Filters" : "Filters active") + .accessibilityLabel(merchFilters.sizes.isEmpty ? "Filters" : "Filters active") } Spacer() @@ -262,7 +263,7 @@ struct ProductsView: View { .sheet(isPresented: $showFilters) { MerchSizeFilter( sizes: availableSizes, - selected: $selectedSizes, + selected: $merchFilters.sizes, showFilters: $showFilters, matchedCount: visibleProducts.count ) @@ -404,7 +405,7 @@ struct MerchSizeFilter: View { /// the already-filtered list to keep the sheet stateless. var matchedCount: Int = 0 - @AppStorage("filterMatchMode") private var filterMatchModeRaw: String = FilterMatchMode.defaultRaw + @AppStorage("filterMatchModeMerch") private var filterMatchModeRaw: String = FilterMatchMode.defaultRaw private let columns = [GridItem(.flexible()), GridItem(.flexible())] var body: some View { diff --git a/hackertracker/Views/SettingsView.swift b/hackertracker/Views/SettingsView.swift index 9e12cfb..083d60d 100644 --- a/hackertracker/Views/SettingsView.swift +++ b/hackertracker/Views/SettingsView.swift @@ -20,6 +20,24 @@ enum IPadSheetSize { case form } +/// Card styling for Settings rows. Mirrors the schedule / content +/// cell look (themed cardSurface, 10pt corners, 8/3 outer padding) +/// so the Settings tab reads as the same card-based surface the rest +/// of the app uses instead of a flat form. +extension View { + @ViewBuilder + func settingsCard(_ themeManager: ThemeManager) -> some View { + self + .padding(.horizontal, 10) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(themeManager.cardSurface) + .cornerRadius(10) + .padding(.horizontal, 8) + .padding(.vertical, 3) + } +} + extension View { /// Sheet sizing for iPad presentations. iOS 18+ uses the native /// `.presentationSizing(...)`. iOS 17 falls back to an explicit @@ -78,7 +96,6 @@ struct SettingsView: View { AboutSettingsView(iPadAction: IPadAdaptive.isIPad ? { iPadSheet = .about } : nil) selectConferenceRow ThemePickerSettingsView(iPadAction: IPadAdaptive.isIPad ? { iPadSheet = .theme } : nil) - Divider() } if IPadAdaptive.isIPad { // iPad: explicit 2-column HStack. Each panel is @@ -176,9 +193,7 @@ struct SettingsView: View { } } .foregroundColor(.primary) - .frame(maxWidth: .infinity) - .background(themeManager.cardSurface) - .cornerRadius(5) + .settingsCard(themeManager) } @ViewBuilder private var conferenceRowLabel: some View { @@ -254,8 +269,7 @@ struct EasterEggSettingsView: View { } } } - .padding(5) - Divider() + .settingsCard(themeManager) } } @@ -266,20 +280,26 @@ struct NotificationSettingsView: View { @State private var showingAlert = false var body: some View { - Text("Notifications") - .font(themeManager.headingFont) - VStack(alignment: .leading) { - Stepper("Before Event: \(notifyAt)", value: $notifyAt, in: 0...60) + VStack(alignment: .leading, spacing: 10) { + Text("Notifications") + .font(themeManager.headingFont) + VStack(alignment: .leading) { + Stepper("Before Event: \(notifyAt)", value: $notifyAt, in: 0...60) Text("Notification time in minutes") .font(themeManager.captionFont) - } - .padding(5) - HStack { + } Button { showingAlert = true } label: { - Text("Remove all notifications") - Image(systemName: "trash") + HStack { + Text("Remove all notifications") + Image(systemName: "trash") + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(5) + .background(themeManager.danger) + .cornerRadius(5) } .alert("Are you sure", isPresented: $showingAlert) { Button("Yes") { @@ -288,16 +308,9 @@ struct NotificationSettingsView: View { Button("No", role: .cancel) { } } } - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding(5) - .background(themeManager.danger) - .cornerRadius(5) - Divider() + .settingsCard(themeManager) ShowConflictAlertView() - Divider() ShowMerchInfoSettingsView() - Divider() } } @@ -327,10 +340,7 @@ struct AboutSettingsView: View { } } .foregroundColor(.primary) - .frame(maxWidth: .infinity) - .background(themeManager.cardSurface) - .cornerRadius(5) - Divider() + .settingsCard(themeManager) } @ViewBuilder private func aboutRowLabel(v1: String, v2: String) -> some View { @@ -562,8 +572,7 @@ struct ShowNewsSettingsView: View { Text("Show the most recent news article on the home screen") .font(themeManager.captionFont) } - .padding(5) - Divider() + .settingsCard(themeManager) } } @@ -580,8 +589,7 @@ struct ShowMerchInfoSettingsView: View { Text("Show the merchandise information link on the merch list") .font(themeManager.captionFont) } - .padding(5) - Divider() + .settingsCard(themeManager) } } @@ -600,8 +608,7 @@ struct ShowPastEventsSettingsView: View { Text("Show or hide past events in the conference schedule") .font(themeManager.captionFont) } - .padding(5) - Divider() + .settingsCard(themeManager) } } @@ -619,13 +626,13 @@ struct ShowConflictAlertView: View { Text("Show the conflict alert icon on the schedule") .font(themeManager.captionFont) } - .padding(5) - Divider() + .settingsCard(themeManager) } } struct LightModeSettingsView: View { @Environment(InfoViewModel.self) private var viewModel + @Environment(ThemeManager.self) private var themeManager @AppStorage("lightMode") var lightMode: Bool = false @AppStorage("colorMode") var colorMode: Bool = false @EnvironmentObject var theme: Theme @@ -633,7 +640,7 @@ struct LightModeSettingsView: View { var body: some View { VStack(alignment: .leading) { Toggle("Enable Light Mode", isOn: $lightMode) - .onChange(of: lightMode) { _, value in + .onChange(of: lightMode) { _, value in Log.ui.debug("lightMode=\(value)") if value { theme.colorScheme = .light @@ -642,17 +649,14 @@ struct LightModeSettingsView: View { } } } - .padding(5) - Divider() + .settingsCard(themeManager) VStack(alignment: .leading) { Toggle("Enable Colorful Mode", isOn: $colorMode) - .onChange(of: colorMode) { _, value in + .onChange(of: colorMode) { _, value in Log.ui.debug("colorMode=\(value)") - //colorMode = value } } - .padding(5) - Divider() + .settingsCard(themeManager) } } @@ -669,6 +673,7 @@ struct StartScreenSettingsView: View { } struct StartScreenPickerView: View { + @Environment(ThemeManager.self) private var themeManager @AppStorage("launchScreen") var launchScreen: String = "Main" let startScreens = ["Main", "Schedule", "Maps"] @@ -682,7 +687,7 @@ struct StartScreenPickerView: View { } .pickerStyle(.segmented) } - .padding(5) + .settingsCard(themeManager) } } @@ -736,18 +741,16 @@ struct ShowLocaltimeSettingsView: View { Text("Show event times in current localtime instead of conference time") .font(themeManager.captionFont) } - .padding(5) - Divider() + .settingsCard(themeManager) VStack(alignment: .leading) { Toggle("Show 24 Hour Time", isOn: $show24hourtime) - .onChange(of: show24hourtime) { _, value in + .onChange(of: show24hourtime) { _, value in Log.ui.debug("show24hourtime=\(value)") } Text("Show event times in 24 hour time (13:00) instead of 12 hour time (1:00 PM)") .font(themeManager.captionFont) } - .padding(5) - Divider() + .settingsCard(themeManager) } } @@ -769,6 +772,15 @@ struct SettingsView_Previews: PreviewProvider { struct AISummarySettingsView: View { @Environment(ThemeManager.self) private var themeManager @AppStorage("aiSummaries") var aiSummaries: Bool = false + /// Hidden gate for AI-generated speaker bios. Off by default and + /// the toggle only becomes visible after a 7-tap chord on the + /// "AI Summaries" row (or stays visible if already on, so users + /// can switch it back off without re-discovering the chord). + @AppStorage("speakerAISummaries") var speakerAISummaries: Bool = false + /// Tap-counter chord. Transient — resets on view rebuild, which + /// is fine because revealing the row is a one-time discovery. + @State private var aiTapCount: Int = 0 + @State private var showSpeakerToggle: Bool = false var body: some View { if AISummaryAvailability.isPossiblyAvailable { @@ -778,6 +790,18 @@ struct AISummarySettingsView: View { .onChange(of: aiSummaries) { _, value in Log.ui.debug("aiSummaries=\(value)") } + // 7-tap chord on the row label reveals the hidden + // Speaker bios toggle below. contentShape on the + // VStack ensures taps on the label area register + // — the Toggle's switch handles its own taps. + .contentShape(Rectangle()) + .onTapGesture { + aiTapCount += 1 + if aiTapCount >= 7 { + showSpeakerToggle = true + aiTapCount = 0 + } + } Text("Show one-sentence summaries of talk descriptions, generated on-device by Apple Intelligence. Summaries are cached and only generated for descriptions longer than 100 characters.") .font(themeManager.captionFont) .foregroundStyle(.secondary) @@ -786,9 +810,31 @@ struct AISummarySettingsView: View { .font(themeManager.captionFont) .foregroundStyle(.tertiary) } + + // Hidden secondary toggle. Visible when (a) revealed + // by the chord this session, or (b) the user already + // turned it on previously — so they can disable it + // without having to re-tap the chord. + if showSpeakerToggle || speakerAISummaries { + Divider() + .padding(.vertical, 4) + Toggle("Speaker bios (experimental)", isOn: $speakerAISummaries) + .disabled(!aiSummaries || !AISummaryAvailability.isSupported) + .onChange(of: speakerAISummaries) { _, value in + Log.ui.debug("speakerAISummaries=\(value)") + } + Text("Also summarize speaker bios on the Speakers list when no job title is provided. Bios shorter than 100 characters render verbatim either way.") + .font(themeManager.captionFont) + .foregroundStyle(.secondary) + } + } + .settingsCard(themeManager) + .onAppear { + // Surface the toggle immediately on appear if the + // flag's already true (the chord only matters when + // it's currently off). + if speakerAISummaries { showSpeakerToggle = true } } - .padding(5) - Divider() } } } @@ -808,8 +854,7 @@ struct ShowCustomEventsSettingsView: View { .font(themeManager.captionFont) .foregroundStyle(.secondary) } - .padding(5) - Divider() + .settingsCard(themeManager) } } @@ -832,10 +877,7 @@ struct ThemePickerSettingsView: View { } } .foregroundColor(.primary) - .frame(maxWidth: .infinity) - .background(themeManager.cardSurface) - .cornerRadius(5) - Divider() + .settingsCard(themeManager) } @ViewBuilder private var themeRowLabel: some View { diff --git a/hackertracker/Views/SpeakerRow.swift b/hackertracker/Views/SpeakerRow.swift index b746484..d92fcae 100644 --- a/hackertracker/Views/SpeakerRow.swift +++ b/hackertracker/Views/SpeakerRow.swift @@ -11,6 +11,77 @@ struct SpeakerRow: View { var speaker: Speaker var themeColor: Color @Environment(ThemeManager.self) private var themeManager + /// Used to look up the speaker's events so we can roll their tag + /// IDs up into the chip strip below the subtitle. + @Environment(InfoViewModel.self) private var viewModel + /// Mirror of the AI summary toggle used by EventCell / ContentCell. + /// When on AND the speaker lacks a job title, we render a one-line + /// AI-generated bio summary in the subtitle slot. + @AppStorage("aiSummaries") private var aiSummaries: Bool = false + /// Hidden secondary gate — speaker bios are only summarized when + /// the user discovers + flips the chord-revealed toggle in + /// Settings → AI Summaries (7-tap on the main row). + @AppStorage("speakerAISummaries") private var speakerAISummaries: Bool = false + + /// True when both the main AI Summaries toggle AND the hidden + /// speaker-specific gate are enabled. + private var aiBiosEnabled: Bool { aiSummaries && speakerAISummaries } + + /// True when the speaker has no provided job-title/subtitle to + /// render. Both nil and whitespace-only titles count as empty so + /// data quirks like `" "` don't suppress the fallback chain. + private var titleIsEmpty: Bool { + (speaker.title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) ?? true + } + + /// Trimmed bio text. Empty string if the speaker has no bio. + private var trimmedBio: String { + speaker.description.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Bios under this length are short enough to fit as a subtitle + /// verbatim. Matches `TalkSummaryCache.minDescriptionChars` so + /// anything we'd otherwise feed to the on-device LLM is also the + /// threshold for "long enough that we should summarize instead". + private static let inlineBioMaxChars = 100 + + /// Distinct event titles for this speaker, in `eventIds` order, + /// comma-joined. Two sessions with the same title get one mention + /// (a speaker doing two slots of "Hands-on Workshop" shouldn't + /// list it twice). + private var eventNamesLine: String { + var seen = Set() + let names = speaker.eventIds + .compactMap { viewModel.eventsById[$0]?.title } + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .filter { seen.insert($0).inserted } + return names.joined(separator: ", ") + } + + /// Unique tag IDs rolled up across every event this speaker is + /// associated with, intersected with the same eligibility set + /// the filter sheet uses (browsable, content-category, not in + /// `SpeakerListConfig.excludedTagTypeLabels`). Mirroring the + /// allow-list keeps chips and filter aligned — and drops rogue + /// tags coming from tagtypes that don't belong on speakers + /// (e.g. "Tool" demo categories). + /// + /// Uses viewModel.eventsById for O(speaker.eventIds) lookup + /// instead of O(speaker.eventIds × all events). + private var speakerTagIds: [Int] { + let eligible: Set = Set( + viewModel.tagtypes + .filter { $0.category == "content" && $0.isBrowsable } + .filter { !SpeakerListConfig.excludedTagTypeLabels.contains($0.label) } + .flatMap { $0.tags.map(\.id) } + ) + let mineTagIds = speaker.eventIds + .compactMap { viewModel.eventsById[$0]?.tagIds } + .flatMap { $0 } + return Array(Set(mineTagIds).intersection(eligible)) + } + var body: some View { HStack { Rectangle().fill(themeColor) @@ -20,11 +91,72 @@ struct SpeakerRow: View { Text(speaker.name) .font(themeManager.headingFont) .foregroundColor(.primary) - if let title = speaker.title { + // Subtitle fallback chain (first match wins): + // 1. Real job title. + // 2. Short bio (< 100 chars) — show verbatim. Same + // threshold as the AI cache so we never leave a + // short bio invisible just because AI is off. + // 3. AI summary, if user has opted in AND the cache + // has produced one for this long-bio speaker. + // 4. Nothing. + if let title = speaker.title, !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Text(title) .font(themeManager.subheadlineFont) .multilineTextAlignment(.leading) - .foregroundColor(.gray) + .foregroundColor(ThemeColors.muted) + } else if !trimmedBio.isEmpty, trimmedBio.count < Self.inlineBioMaxChars { + Text(trimmedBio) + .font(themeManager.subheadlineFont) + .multilineTextAlignment(.leading) + .foregroundColor(ThemeColors.muted) + } else if aiBiosEnabled, + let summary = TalkSummaryCache.shared.summary(for: speaker) { + // AI summary slot — same styling as the talk-cell + // sparkle line. Only reachable when the bio is + // long enough that the cache would actually + // summarize it. + HStack(alignment: .top, spacing: 4) { + Image(systemName: "sparkles") + .font(themeManager.captionFont) + .foregroundColor(ThemeColors.muted) + .padding(.top, 2) + Text(summary) + .font(themeManager.captionFont) + .foregroundColor(ThemeColors.muted) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("AI summary: \(summary)") + } + // Event titles this speaker is presenting. Calendar + // glyph + comma-joined unique titles. Sits between + // the subtitle fallback chain and the chip strip so + // the row reads top-down: who → what they do → + // what they're presenting → categorical chips. + if !eventNamesLine.isEmpty { + HStack(alignment: .top, spacing: 4) { + Image(systemName: "calendar") + .font(themeManager.captionFont) + .foregroundColor(ThemeColors.muted) + .padding(.top, 2) + Text(eventNamesLine) + .font(themeManager.captionFont) + .foregroundColor(ThemeColors.muted) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + .padding(.top, 2) + } + // Tag chip strip — Event Category / Organizer / etc. + // rolled up across the speaker's events. Reuses the + // exact chip renderer the schedule + content rows + // use so the visual family stays consistent. Skipped + // when the speaker has no events yet (early load, or + // a speaker not connected to any tagged event). + if !speakerTagIds.isEmpty { + ShowEventCellTags(tagIds: speakerTagIds) + .padding(.top, 2) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -39,6 +171,18 @@ struct SpeakerRow: View { .cornerRadius(10) .padding(.horizontal, 8) .padding(.vertical, 3) + // Opportunistic warm on materialization. The cache's own + // gating handles availability + dedup, but we additionally + // require titleIsEmpty AND a long bio — otherwise the row + // would render its title or the short-bio fallback verbatim + // and the summary would never display. + .task { + if aiBiosEnabled, + titleIsEmpty, + trimmedBio.count >= Self.inlineBioMaxChars { + TalkSummaryCache.shared.warm(speaker) + } + } } } diff --git a/hackertracker/Views/SpeakersView.swift b/hackertracker/Views/SpeakersView.swift index 531d947..9718c6f 100644 --- a/hackertracker/Views/SpeakersView.swift +++ b/hackertracker/Views/SpeakersView.swift @@ -11,6 +11,8 @@ struct SpeakersView: View { var speakers: [Speaker] @Environment(InfoViewModel.self) private var viewModel @Environment(ThemeManager.self) private var themeManager + @EnvironmentObject var speakerFilters: SpeakerFiltersStore + @AppStorage("filterMatchModeSpeakers") private var filterMatchModeRaw: String = FilterMatchMode.defaultRaw @State private var searchText = "" // Polish parity with schedule / All Content. @@ -18,11 +20,103 @@ struct SpeakersView: View { @FocusState private var searchFocused: Bool @State private var scrollToGroup: String.Element? @State private var lastJumpedGroup: String.Element? + @State private var showFilters: Bool = false /// iPad-only: identifies the speaker currently shown in the detail column. @State private var ipadSelectedSpeakerId: Int? + /// Precomputed `speaker.id → Set` mapping. Rebuilt only + /// when the underlying speakers / events / tagtypes lists change + /// (see the `.task(id:)` modifier below). The filter pipeline and + /// chip-pool computation both read from this dict so every render + /// is O(speakers) instead of O(speakers × events). + @State private var speakerTagIdsMap: [Int: Set] = [:] + + private var filterMatchMode: FilterMatchMode { + FilterMatchMode(rawOrDefault: filterMatchModeRaw) + } + + /// Set of tag IDs that *are* eligible to be surfaced as speaker + /// chips / filter chips — browsable, category=="content", and + /// not in the excluded-tagtype-labels set. The intersection + /// guards against rogue tag IDs (events tagged with something + /// from a non-displayed or filtered-out tagtype, e.g. "Tool") + /// leaking into the speakers list. + private var eligibleTagIds: Set { + Set( + viewModel.tagtypes + .filter { $0.category == "content" && $0.isBrowsable } + .filter { !SpeakerListConfig.excludedTagTypeLabels.contains($0.label) } + .flatMap { $0.tags.map(\.id) } + ) + } + + /// Rebuild the speaker→tagIds map. Only runs when the underlying + /// data changes (events / speakers / tagtypes count), not on every + /// keystroke or chip tap. O(speakers × avg-eventsPerSpeaker) once, + /// then O(1) lookups everywhere downstream. + private func rebuildSpeakerTagIdsMap() { + let eligible = eligibleTagIds + let eventsById = viewModel.eventsById + var map: [Int: Set] = [:] + for speaker in speakers { + let tags = speaker.eventIds + .compactMap { eventsById[$0]?.tagIds } + .flatMap { $0 } + map[speaker.id] = Set(tags).intersection(eligible) + } + speakerTagIdsMap = map + } + + /// Apply search + the speakerFilters chip selection using the + /// precomputed per-speaker tag-id map. O(speakers) per render + /// instead of O(speakers × events). Search includes the speaker's + /// event titles so a user looking up "BadgeLife" finds the + /// speakers presenting that talk even if their name doesn't match. + private var filteredSpeakers: [Speaker] { + let searched = speakers.search(text: searchText, eventsById: viewModel.eventsById) + let selected = speakerFilters.filters + guard !selected.isEmpty else { return searched } + return searched.filter { speaker in + let st = speakerTagIdsMap[speaker.id] ?? [] + switch filterMatchMode { + case .any: return !st.isDisjoint(with: selected) + case .all: return selected.isSubset(of: st) + } + } + } + + /// Subset of conference tagtypes that actually appear in this + /// speakers list. Smaller pool than the schedule's filter because + /// speakers are bounded by their participation — we only show + /// chips for tags at least one speaker is connected to. + /// + /// Computed directly from raw data (using `viewModel.eventsById` + /// for O(1) lookups) rather than reading the precomputed + /// `speakerTagIdsMap`, because the map is rebuilt asynchronously + /// via `.task(id:)` and could be empty the first time the user + /// presents the filter sheet — which produced an empty chip + /// pool and a blank sheet. This computation only runs when the + /// sheet actually opens, so the O(speakers × avgEvents) cost is + /// one-shot, not per-render. + private var availableTagTypes: [TagType] { + let eventsById = viewModel.eventsById + let speakerTagPool: Set = Set( + speakers + .flatMap { $0.eventIds } + .compactMap { eventsById[$0]?.tagIds } + .flatMap { $0 } + ) + return viewModel.tagtypes + .filter { $0.category == "content" && $0.isBrowsable } + .filter { !SpeakerListConfig.excludedTagTypeLabels.contains($0.label) } + .compactMap { tagtype in + var copy = tagtype + copy.tags = tagtype.tags.filter { speakerTagPool.contains($0.id) } + return copy.tags.isEmpty ? nil : copy + } + } private var grouped: [(key: String.Element, value: [Speaker])] { - Dictionary(grouping: speakers.search(text: searchText), + Dictionary(grouping: filteredSpeakers, by: { $0.name.lowercased().first ?? "-" }) .sorted { $0.key < $1.key } } @@ -149,7 +243,23 @@ struct SpeakersView: View { } .overlay(alignment: .bottom) { HStack { + // Filter circle on the leading side, mirroring + // Schedule's affordance placement. + Button { + showFilters.toggle() + } label: { + Image(systemName: speakerFilters.filters.isEmpty + ? "line.3.horizontal.decrease.circle" + : "line.3.horizontal.decrease.circle.fill") + .font(themeManager.title2Font) + .frame(width: 48, height: 48) + .background(.regularMaterial, in: Circle()) + } + .tint(.primary) + .accessibilityLabel(speakerFilters.filters.isEmpty ? "Filters" : "Filters active") + Spacer() + jumpToGroupMenu .font(themeManager.title2Font) .foregroundStyle(.primary) @@ -160,6 +270,21 @@ struct SpeakersView: View { .padding(.horizontal, 20) .padding(.bottom, 12) } + .sheet(isPresented: $showFilters) { + SpeakerFiltersSheet( + tagtypes: availableTagTypes, + showFilters: $showFilters, + matchedCount: filteredSpeakers.count + ) + } + // Rebuild the speaker→tagIds map only when one of the + // underlying lists actually changes. Keystrokes + chip taps + // run filteredSpeakers against the cached map (O(speakers)) + // rather than re-scanning viewModel.events for every speaker + // on every render (O(speakers × events)). + .task(id: "\(speakers.count)-\(viewModel.events.count)-\(viewModel.tagtypes.count)") { + rebuildSpeakerTagIdsMap() + } .navigationTitle("Speakers") .themedNavTitle("Speakers", themeManager) .navigationBarTitleDisplayMode(.inline) @@ -227,6 +352,12 @@ struct SpeakerData: View { NavigationLink(destination: SpeakerDetailView(id: speaker.id)) { SpeakerRow(speaker: speaker, themeColor: theme.carousel()) } + // Without .plain, NavigationLink tints every + // child view with the system accent color (system + // blue), which paints the row's tag-chip dots + // blue regardless of each tag's colorBackground. + // Matches the EventCell / ContentCell wrappers. + .buttonStyle(.plain) } } } @@ -240,3 +371,121 @@ struct SpeakersView_Previews: PreviewProvider { } } } + +/// Filter sheet for the Speakers list. Mirrors the chrome of +/// `EventFilters` (Match Any/All picker + live tally + tagtype- +/// grouped chip grid + Clear/Done toolbar buttons) so users see the +/// same shape they already know from Schedule. Differences: +/// +/// - Reads `SpeakerFiltersStore` instead of `Filters`, so toggling a +/// chip here doesn't bleed into Schedule / All Content selections. +/// - Persists Match mode under "filterMatchModeSpeakers" so the +/// speakers list mode is independent from the schedule's. +/// - No pseudo-tag chips. Bookmarks don't apply to speakers and +/// notes aren't authored on speakers at present — re-add when +/// either becomes meaningful for this list. +/// - Tag pool is already pre-filtered upstream by `SpeakersView` to +/// only the tags at least one visible speaker is connected to. +struct SpeakerFiltersSheet: View { + let tagtypes: [TagType] + @Binding var showFilters: Bool + @EnvironmentObject var speakerFilters: SpeakerFiltersStore + @Environment(ThemeManager.self) private var themeManager + /// Same shape as EventFilters' matchedCount — caller computes + /// using the same filter pipeline so the displayed tally is the + /// row count the list will render. + var matchedCount: Int = 0 + @AppStorage("filterMatchModeSpeakers") private var filterMatchModeRaw: String = FilterMatchMode.defaultRaw + + let gridItemLayout = [GridItem(.flexible()), GridItem(.flexible())] + + var body: some View { + NavigationStack { + ScrollView { + MatchModePickerRow(raw: $filterMatchModeRaw) + FilterMatchCountLabel(count: matchedCount, unit: "speaker") + + ForEach(tagtypes.sorted { $0.sortOrder < $1.sortOrder }) { tagtype in + Section { + LazyVGrid(columns: gridItemLayout, alignment: .center, spacing: 10) { + ForEach(tagtype.tags.sorted { $0.sortOrder < $1.sortOrder }) { tag in + SpeakerFilterRow( + id: tag.id, + name: tag.label, + color: Color(UIColor(hex: tag.colorBackground ?? "#2c8f07") ?? .purple) + ) + } + } + } header: { + Text(tagtype.label) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .headerProminence(.increased) + } + .padding(.horizontal, 10) + .navigationTitle("Filters") + .themedNavTitle("Filters", themeManager) + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.ultraThinMaterial, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Clear") { + speakerFilters.filters.removeAll() + } + .disabled(speakerFilters.filters.isEmpty) + } + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + showFilters = false + } + .bold() + } + } + } + } +} + +/// Speaker-list flavored copy of FilterRow. Same chip styling as the +/// schedule's row but writes to `SpeakerFiltersStore`. A separate +/// struct (vs. parameterizing FilterRow with a generic store) keeps +/// EnvironmentObject typing clean — both Filters and +/// SpeakerFiltersStore can be present in the env without one chip +/// row picking the wrong store. +struct SpeakerFilterRow: View { + let id: Int + let name: String + let color: Color + @EnvironmentObject var speakerFilters: SpeakerFiltersStore + @Environment(ThemeManager.self) private var themeManager + + var body: some View { + Button(action: { + if speakerFilters.filters.contains(id) { + speakerFilters.filters.remove(id) + } else { + speakerFilters.filters.insert(id) + } + Log.ui.debug("SpeakerFilters=\(speakerFilters.filters)") + }) { + VStack(alignment: .leading) { + HStack { + Text(name) + .font(themeManager.subheadlineFont) + .padding(5) + } + } + .foregroundColor(speakerFilters.filters.contains(id) ? .white : .primary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding(5) + .background(speakerFilters.filters.contains(id) ? color : Color.clear) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(color, lineWidth: 1.5) + ) + } + .buttonStyle(.plain) + } +}