From 73a1bddf1708bb568ef85e7a74d82e6eb31399d5 Mon Sep 17 00:00:00 2001 From: Seth Law Date: Sat, 20 Jun 2026 21:14:37 -0600 Subject: [PATCH 01/15] AI summary: speakers without a job title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Speakers list rows now show a one-line AI-generated summary in place of the missing job-title/subtitle when: - the user has Apple Intelligence summaries enabled (Settings → AI Summaries — same toggle that drives talk summaries) - the speaker's `title` is nil or whitespace-only - the speaker's bio is ≥ 100 chars (the cache's existing minDescriptionChars guard — short bios aren't worth summarizing) The sparkle + caption layout matches EventCell and ContentCell so the affordance reads consistently across the three list types. Speakers with a real title keep showing that title verbatim — no overlap with AI text. Implementation: - `SummarizableTalk` protocol gains `summaryCacheKey: String` and `summaryKind: SummaryKind` with default implementations. Speaker overrides both to namespace its cache entries ("speaker:\(id)") and select the speaker-flavored prompt. Content + Event keep the defaults so existing cache entries continue to resolve under their bare integer keys. - TalkSummaryCache storage migrates from `[Int: Entry]` to `[String: Entry]`. Legacy persisted payloads (the previous `[Int: Entry]` JSON) are detected and folded forward in `load()` so users don't lose their existing talk summaries. - New prompt variant for `.speaker`: optimized for "what is this person known for, in 10 words" rather than the talk prompt's "what will the audience learn". Co-Authored-By: WOZCODE --- hackertracker/Utils/TalkSummaryCache.swift | 145 ++++++++++++++------- hackertracker/Views/SpeakerRow.swift | 41 +++++- 2 files changed, 136 insertions(+), 50 deletions(-) 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/Views/SpeakerRow.swift b/hackertracker/Views/SpeakerRow.swift index b746484..cb96b41 100644 --- a/hackertracker/Views/SpeakerRow.swift +++ b/hackertracker/Views/SpeakerRow.swift @@ -11,6 +11,18 @@ struct SpeakerRow: View { var speaker: Speaker var themeColor: Color @Environment(ThemeManager.self) private var themeManager + /// 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 + + /// 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 summary fallback. + private var titleIsEmpty: Bool { + (speaker.title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) ?? true + } + var body: some View { HStack { Rectangle().fill(themeColor) @@ -20,11 +32,28 @@ struct SpeakerRow: View { Text(speaker.name) .font(themeManager.headingFont) .foregroundColor(.primary) - if let title = speaker.title { + if let title = speaker.title, !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Text(title) .font(themeManager.subheadlineFont) .multilineTextAlignment(.leading) .foregroundColor(.gray) + } else if aiSummaries, + let summary = TalkSummaryCache.shared.summary(for: speaker) { + // AI summary slot — only when there's no title to + // show. Same styling as the talk-cell sparkle line. + HStack(alignment: .top, spacing: 4) { + Image(systemName: "sparkles") + .font(themeManager.captionFont) + .foregroundStyle(.secondary) + .padding(.top, 2) + Text(summary) + .font(themeManager.captionFont) + .foregroundStyle(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("AI summary: \(summary)") } } .frame(maxWidth: .infinity, alignment: .leading) @@ -39,6 +68,16 @@ struct SpeakerRow: View { .cornerRadius(10) .padding(.horizontal, 8) .padding(.vertical, 3) + // Opportunistic warm on materialization. The cache's own + // gating handles minDescriptionChars + availability + dedup, + // so the only thing we owe it here is the "title is empty" + // filter — otherwise we'd burn battery generating summaries + // we'll never display. + .task { + if aiSummaries && titleIsEmpty { + TalkSummaryCache.shared.warm(speaker) + } + } } } From cc04ecaaf7c23ffc860d6411a7da1aea5cb2f1b9 Mon Sep 17 00:00:00 2001 From: Seth Law Date: Sat, 20 Jun 2026 21:49:15 -0600 Subject: [PATCH 02/15] Speakers: show short bio as subtitle when no title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subtitle fallback chain in SpeakerRow is now: 1. Speaker has a real title → show title. 2. No title, bio under 100 chars → show bio verbatim (no AI needed, no toggle dependency). 3. No title, bio ≥ 100 chars, AI Summaries on, cache has a summary → show the sparkle + AI summary. 4. Otherwise → just the name, no subtitle. The 100-char threshold matches TalkSummaryCache.minDescriptionChars so short bios are never invisible (the cache wouldn't summarize them anyway). The warm-the-cache gate now also requires bio.count ≥ 100 so we don't burn battery generating summaries for bios that already fit as subtitles. Co-Authored-By: WOZCODE --- hackertracker/Views/SpeakerRow.swift | 44 +++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/hackertracker/Views/SpeakerRow.swift b/hackertracker/Views/SpeakerRow.swift index cb96b41..1065c05 100644 --- a/hackertracker/Views/SpeakerRow.swift +++ b/hackertracker/Views/SpeakerRow.swift @@ -18,11 +18,22 @@ struct SpeakerRow: View { /// 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 summary fallback. + /// 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 + var body: some View { HStack { Rectangle().fill(themeColor) @@ -32,15 +43,30 @@ struct SpeakerRow: View { Text(speaker.name) .font(themeManager.headingFont) .foregroundColor(.primary) + // 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) + } else if !trimmedBio.isEmpty, trimmedBio.count < Self.inlineBioMaxChars { + Text(trimmedBio) + .font(themeManager.subheadlineFont) + .multilineTextAlignment(.leading) + .foregroundColor(.gray) } else if aiSummaries, let summary = TalkSummaryCache.shared.summary(for: speaker) { - // AI summary slot — only when there's no title to - // show. Same styling as the talk-cell sparkle line. + // 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) @@ -69,12 +95,14 @@ struct SpeakerRow: View { .padding(.horizontal, 8) .padding(.vertical, 3) // Opportunistic warm on materialization. The cache's own - // gating handles minDescriptionChars + availability + dedup, - // so the only thing we owe it here is the "title is empty" - // filter — otherwise we'd burn battery generating summaries - // we'll never display. + // 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 aiSummaries && titleIsEmpty { + if aiSummaries, + titleIsEmpty, + trimmedBio.count >= Self.inlineBioMaxChars { TalkSummaryCache.shared.warm(speaker) } } From a4348eb3818c1b4806a2147133dccfa64499b7e8 Mon Sep 17 00:00:00 2001 From: Seth Law Date: Sat, 20 Jun 2026 21:55:32 -0600 Subject: [PATCH 03/15] AI summary text: switch to Color.gray for readability on themed cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sparkle + summary line in EventCell, ContentCell, and SpeakerRow used .foregroundStyle(.secondary), which on dark themed cardSurfaces (Hacker Green, Synthwave, Vegas, DEF CON Red dark variants) faded into illegibility — .secondary derives from system label tones that were calibrated for system backgrounds, not the theme palette. Match the speaker-title gray that's been working fine on every themed surface: .foregroundColor(.gray) — a fixed mid-gray that stays readable across all light/dark theme variants without adapting itself out of contrast. Co-Authored-By: WOZCODE --- hackertracker/Views/ContentCellView.swift | 4 ++-- hackertracker/Views/EventCellView.swift | 4 ++-- hackertracker/Views/SpeakerRow.swift | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/hackertracker/Views/ContentCellView.swift b/hackertracker/Views/ContentCellView.swift index 141d404..deec050 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(.gray) .padding(.top, 2) Text(summary) .font(themeManager.captionFont) - .foregroundStyle(.secondary) + .foregroundColor(.gray) .lineLimit(2) .multilineTextAlignment(.leading) } diff --git a/hackertracker/Views/EventCellView.swift b/hackertracker/Views/EventCellView.swift index 7e4f917..7163155 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(.gray) .padding(.top, 2) Text(summary) .font(themeManager.captionFont) - .foregroundStyle(.secondary) + .foregroundColor(.gray) .lineLimit(2) .multilineTextAlignment(.leading) } diff --git a/hackertracker/Views/SpeakerRow.swift b/hackertracker/Views/SpeakerRow.swift index 1065c05..1b8c64a 100644 --- a/hackertracker/Views/SpeakerRow.swift +++ b/hackertracker/Views/SpeakerRow.swift @@ -70,11 +70,11 @@ struct SpeakerRow: View { HStack(alignment: .top, spacing: 4) { Image(systemName: "sparkles") .font(themeManager.captionFont) - .foregroundStyle(.secondary) + .foregroundColor(.gray) .padding(.top, 2) Text(summary) .font(themeManager.captionFont) - .foregroundStyle(.secondary) + .foregroundColor(.gray) .lineLimit(2) .multilineTextAlignment(.leading) } From e0120ad96251ed22d9d433b155a15acc83c92dc9 Mon Sep 17 00:00:00 2001 From: Seth Law Date: Sat, 20 Jun 2026 23:04:54 -0600 Subject: [PATCH 04/15] Swift 6 strict-concurrency: clear NotificationUtility + Theme warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NotificationUtility.swift — `UNNotificationRequest` isn't yet annotated Sendable in UserNotifications. The non-Sendable capture in `addNotification`'s closure tripped strict-concurrency diagnostics. Add `@preconcurrency` to the import so the framework's Sendable diagnostics downgrade to warnings until Apple ships the annotations. Theme.swift — `applyNavBarAppearance` touches `UINavigationBar.appearance()`, `UINavigationBarAppearance.init`, and the various `*Appearance` mutators which are all `@MainActor`-isolated under Swift 6. Mark `ThemeManager` `@MainActor` so its static + instance methods can call those APIs cleanly. Every call site is already on the main actor (SwiftUI view bodies, `@State` init, the App's `init()` which runs under the `@MainActor`-isolated `App` protocol). Co-Authored-By: WOZCODE --- hackertracker/Utils/NotificationUtility.swift | 6 +++++- hackertracker/Utils/Theme.swift | 10 +++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) 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/Theme.swift b/hackertracker/Utils/Theme.swift index e69f10b..489ac6d 100644 --- a/hackertracker/Utils/Theme.swift +++ b/hackertracker/Utils/Theme.swift @@ -480,6 +480,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 +511,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 +529,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) From 7d18187337c1ebc89bb73ff43b3920e838affc22 Mon Sep 17 00:00:00 2001 From: Seth Law Date: Sun, 21 Jun 2026 08:58:17 -0600 Subject: [PATCH 05/15] Speakers: hidden AI-bio gate + tag chip rollup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Hidden gate The "AI Summaries" toggle in Settings is the primary on/off for talk summaries. Speaker bios get a separate, hidden gate so the feature ships behind a discoverable chord rather than as a top-level toggle: - New @AppStorage("speakerAISummaries") bool, default false. - AISummarySettingsView gains a 7-tap chord on the main row label. Once triggered (or whenever speakerAISummaries is already true), a "Speaker bios (experimental)" Toggle appears below the main caption with its own footer copy. - SpeakerRow's AI summary branch now requires aiSummaries AND speakerAISummaries — and the cache-warming task uses the same combined gate so we never generate summaries that won't display. # Tag chip rollup SpeakerRow now rolls up the unique tag IDs across every event a speaker is associated with, then renders them through the existing ShowEventCellTags component. Speakers list now reads as the same design family as schedule / content cells: leading color stripe, name + subtitle, then a chip strip showing Event Category, Organizer, etc. — whichever browsable tags the speaker's work falls under. Skipped when the speaker has no events yet (cold load, or a speaker disconnected from the catalog). Performance: SpeakerData uses lazy materialization so only visible rows compute their tag rollup. Pool is bounded by viewModel.events which is already in-memory. # Filter pass — planned, not implemented Speakers list filter is on the followup list: floating filter circle on SpeakersView, sheet sharing MatchModePickerRow + FilterMatchCountLabel chrome, chip pool derived from the union of tag IDs across all speakers' events (much smaller pool than the schedule's), new @AppStorage("filterMatchModeSpeakers") so modes stay independent, Has Notes pseudo-tag (no Bookmarks — n/a to speakers). Co-Authored-By: WOZCODE --- hackertracker/Views/SettingsView.swift | 44 ++++++++++++++++++++++++++ hackertracker/Views/SpeakerRow.swift | 35 ++++++++++++++++++-- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/hackertracker/Views/SettingsView.swift b/hackertracker/Views/SettingsView.swift index 9e12cfb..04dafc7 100644 --- a/hackertracker/Views/SettingsView.swift +++ b/hackertracker/Views/SettingsView.swift @@ -769,6 +769,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 +787,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,8 +807,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) + } } .padding(5) + .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 } + } Divider() } } diff --git a/hackertracker/Views/SpeakerRow.swift b/hackertracker/Views/SpeakerRow.swift index 1b8c64a..d15fc9d 100644 --- a/hackertracker/Views/SpeakerRow.swift +++ b/hackertracker/Views/SpeakerRow.swift @@ -11,10 +11,21 @@ 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 @@ -34,6 +45,16 @@ struct SpeakerRow: View { /// threshold for "long enough that we should summarize instead". private static let inlineBioMaxChars = 100 + /// Unique tag IDs rolled up across every event this speaker is + /// associated with. Drives the chip strip — gives the user a + /// glanceable signal of what kind of work this speaker does + /// (Event Category + Organizer chips, etc.) without having to + /// tap into the detail screen. + private var speakerTagIds: [Int] { + let mine = viewModel.events.filter { speaker.eventIds.contains($0.id) } + return Array(Set(mine.flatMap(\.tagIds))) + } + var body: some View { HStack { Rectangle().fill(themeColor) @@ -61,7 +82,7 @@ struct SpeakerRow: View { .font(themeManager.subheadlineFont) .multilineTextAlignment(.leading) .foregroundColor(.gray) - } else if aiSummaries, + } 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 @@ -81,6 +102,16 @@ struct SpeakerRow: View { .accessibilityElement(children: .combine) .accessibilityLabel("AI summary: \(summary)") } + // 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) .padding(.vertical, 10) @@ -100,7 +131,7 @@ struct SpeakerRow: View { // would render its title or the short-bio fallback verbatim // and the summary would never display. .task { - if aiSummaries, + if aiBiosEnabled, titleIsEmpty, trimmedBio.count >= Self.inlineBioMaxChars { TalkSummaryCache.shared.warm(speaker) From 8021d3bd314614d76b67875c1f1f2ac093e1bc3b Mon Sep 17 00:00:00 2001 From: Seth Law Date: Sun, 21 Jun 2026 09:03:41 -0600 Subject: [PATCH 06/15] Speakers: filter sheet + filtered pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the filter portion of the speakers polish that was previously just a plan. # What ships - Floating filter circle on SpeakersView (leading side of the bottom overlay, matching Schedule's affordance placement). Icon switches to filled when any chip is selected. - New SpeakerFiltersSheet — same chrome as EventFilters (MatchModePickerRow + FilterMatchCountLabel + tagtype-grouped chip grid + Clear/Done toolbar). Distinct because it talks to a separate state store. - Speakers list is now filtered through the same Any/All semantics used by Schedule + All Content. Each speaker's "rolled-up" tag IDs (union across their events, same set that drives the chip strip) is checked against the selected filter set. - Tag pool is pre-narrowed to only tags that at least one visible speaker is connected to — a smaller pool than the schedule's filter sheet, because speakers are bounded by participation. # Plumbing - New `SpeakerFiltersStore: ObservableObject` (distinct class from `Filters` so both can sit in the SwiftUI environment without type collision). Holds `Set` of selected tag IDs. - Injected as `@StateObject` in ContentView and threaded through every existing `.environmentObject(filters)` site. - New `@AppStorage("filterMatchModeSpeakers")` so the speakers filter mode is independent of the schedule's. - New `SpeakerFilterRow` chip view that reads `SpeakerFiltersStore` directly — separate struct from `FilterRow` (which reads `Filters`) so EnvironmentObject typing stays unambiguous. # Not yet wired Pseudo-tag chips (Bookmarks / Has Notes / Custom Events) — none currently apply to speakers. Bookmarks aren't a speaker concept; Notes aren't authored on speakers; CustomEvents are user-created schedule entries. Easy to add per-pseudo-tag later when one becomes meaningful for this list. Co-Authored-By: WOZCODE --- hackertracker/Utils/Filters.swift | 15 +- hackertracker/Views/ContentView.swift | 6 + hackertracker/Views/SpeakersView.swift | 194 ++++++++++++++++++++++++- 3 files changed, 213 insertions(+), 2 deletions(-) diff --git a/hackertracker/Utils/Filters.swift b/hackertracker/Utils/Filters.swift index 3e56dde..939d50f 100644 --- a/hackertracker/Utils/Filters.swift +++ b/hackertracker/Utils/Filters.swift @@ -9,8 +9,21 @@ import Foundation class Filters: ObservableObject { @Published var filters: Set - + init(filters: Set) { self.filters = 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 { + @Published var filters: Set + + init(filters: Set = []) { + self.filters = filters + } +} diff --git a/hackertracker/Views/ContentView.swift b/hackertracker/Views/ContentView.swift index 26286ab..6f7fbde 100644 --- a/hackertracker/Views/ContentView.swift +++ b/hackertracker/Views/ContentView.swift @@ -50,6 +50,9 @@ 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() @State private var tabSelection = 1 // @State private var tappedMainTwice = false // @State private var tappedScheduleTwice = false @@ -157,6 +160,7 @@ struct ContentView: View { .environmentObject(toCurrent) .environmentObject(toNext) .environmentObject(filters) + .environmentObject(speakerFilters) // 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 +186,7 @@ struct ContentView: View { .environmentObject(theme) .environment(themeManager) .environmentObject(filters) + .environmentObject(speakerFilters) } else { _04View(message: "Loading", show404: false).preferredColorScheme(theme.colorScheme) .preferredColorScheme(theme.colorScheme) @@ -191,6 +196,7 @@ struct ContentView: View { .environmentObject(theme) .environment(themeManager) .environmentObject(filters) + .environmentObject(speakerFilters) .task { Log.app.debug("ContentView selected=\(selected.code, privacy: .public) stored=\(conferenceCode, privacy: .public)") if selected.code != conferenceCode { diff --git a/hackertracker/Views/SpeakersView.swift b/hackertracker/Views/SpeakersView.swift index 531d947..48e24b9 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,60 @@ 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? + private var filterMatchMode: FilterMatchMode { + FilterMatchMode(rawOrDefault: filterMatchModeRaw) + } + + /// Rolls a speaker's events up into their unique tag IDs. Same + /// logic as SpeakerRow's chip strip — keep them aligned so the + /// filter selects on exactly what the chips display. + private func tagIds(for speaker: Speaker) -> Set { + let mine = viewModel.events.filter { speaker.eventIds.contains($0.id) } + return Set(mine.flatMap(\.tagIds)) + } + + /// Apply search + the speakerFilters chip selection. Same Any/All + /// semantics as the schedule's filter pipeline so behavior reads + /// the same way to a user toggling between tabs. + private var filteredSpeakers: [Speaker] { + let searched = speakers.search(text: searchText) + let selected = speakerFilters.filters + guard !selected.isEmpty else { return searched } + return searched.filter { speaker in + let st = tagIds(for: speaker) + 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. + private var availableTagTypes: [TagType] { + let speakerTagPool: Set = Set( + speakers + .flatMap { $0.eventIds } + .compactMap { id in viewModel.events.first(where: { $0.id == id })?.tagIds } + .flatMap { $0 } + ) + return viewModel.tagtypes + .filter { $0.category == "content" && $0.isBrowsable } + .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 +200,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 +227,13 @@ struct SpeakersView: View { .padding(.horizontal, 20) .padding(.bottom, 12) } + .sheet(isPresented: $showFilters) { + SpeakerFiltersSheet( + tagtypes: availableTagTypes, + showFilters: $showFilters, + matchedCount: filteredSpeakers.count + ) + } .navigationTitle("Speakers") .themedNavTitle("Speakers", themeManager) .navigationBarTitleDisplayMode(.inline) @@ -240,3 +314,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) + } +} From 4f1b808660a1fa7b8a94c82f255188d288faab27 Mon Sep 17 00:00:00 2001 From: Seth Law Date: Sun, 21 Jun 2026 09:08:19 -0600 Subject: [PATCH 07/15] Speakers: drop Skill Level + Modality from chips and filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These tagtypes live on events (a talk has a skill level, a modality — hybrid / online / in-person), but they don't read as useful speaker metadata. A speaker isn't "Beginner" or "Hybrid"; their talk is. Surface only the categorical / organizational tags (Event Category, Organizer, etc.) on the speakers list. Added `SpeakerListConfig.excludedTagTypeLabels` next to `SpeakerFiltersStore` in Filters.swift so all three call sites share the same exclusion list: - SpeakerRow's chip rollup - SpeakersView's availableTagTypes (filter sheet chip pool) - SpeakersView's tagIds(for:) (filter pipeline match) Exclusion happens by tagtype.label rather than id, because tagtype ids vary across conferences but the canonical labels are stable in the Firestore data. Co-Authored-By: WOZCODE --- hackertracker/Utils/Filters.swift | 13 +++++++++++++ hackertracker/Views/SpeakerRow.swift | 17 ++++++++++++----- hackertracker/Views/SpeakersView.swift | 22 ++++++++++++++++++---- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/hackertracker/Utils/Filters.swift b/hackertracker/Utils/Filters.swift index 939d50f..1fcf23d 100644 --- a/hackertracker/Utils/Filters.swift +++ b/hackertracker/Utils/Filters.swift @@ -27,3 +27,16 @@ final class SpeakerFiltersStore: ObservableObject { self.filters = filters } } + +/// 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. + static let excludedTagTypeLabels: Set = ["Skill Level", "Modality"] +} diff --git a/hackertracker/Views/SpeakerRow.swift b/hackertracker/Views/SpeakerRow.swift index d15fc9d..4a607b3 100644 --- a/hackertracker/Views/SpeakerRow.swift +++ b/hackertracker/Views/SpeakerRow.swift @@ -46,13 +46,20 @@ struct SpeakerRow: View { private static let inlineBioMaxChars = 100 /// Unique tag IDs rolled up across every event this speaker is - /// associated with. Drives the chip strip — gives the user a - /// glanceable signal of what kind of work this speaker does - /// (Event Category + Organizer chips, etc.) without having to - /// tap into the detail screen. + /// associated with, minus tagtypes that are intentionally hidden + /// from the speakers list (see `SpeakerListConfig`). Drives the + /// chip strip — gives the user a glanceable signal of what kind + /// of work this speaker does (Event Category, Organizer, etc.) + /// without having to tap into the detail screen. private var speakerTagIds: [Int] { let mine = viewModel.events.filter { speaker.eventIds.contains($0.id) } - return Array(Set(mine.flatMap(\.tagIds))) + let all = Set(mine.flatMap(\.tagIds)) + let excluded: Set = Set( + viewModel.tagtypes + .filter { SpeakerListConfig.excludedTagTypeLabels.contains($0.label) } + .flatMap { $0.tags.map(\.id) } + ) + return Array(all.subtracting(excluded)) } var body: some View { diff --git a/hackertracker/Views/SpeakersView.swift b/hackertracker/Views/SpeakersView.swift index 48e24b9..18a0917 100644 --- a/hackertracker/Views/SpeakersView.swift +++ b/hackertracker/Views/SpeakersView.swift @@ -28,12 +28,25 @@ struct SpeakersView: View { FilterMatchMode(rawOrDefault: filterMatchModeRaw) } - /// Rolls a speaker's events up into their unique tag IDs. Same - /// logic as SpeakerRow's chip strip — keep them aligned so the - /// filter selects on exactly what the chips display. + /// Tag IDs from tagtypes intentionally hidden from the speakers + /// list (`SpeakerListConfig.excludedTagTypeLabels`). Cached as a + /// computed property so the filter pipeline and the chip-pool + /// computation both pull from the same exclusion set. + private var excludedTagIds: Set { + Set( + viewModel.tagtypes + .filter { SpeakerListConfig.excludedTagTypeLabels.contains($0.label) } + .flatMap { $0.tags.map(\.id) } + ) + } + + /// Rolls a speaker's events up into their unique tag IDs minus + /// the excluded tagtypes. Same logic as SpeakerRow's chip strip + /// — keep them aligned so the filter selects on exactly what the + /// chips display. private func tagIds(for speaker: Speaker) -> Set { let mine = viewModel.events.filter { speaker.eventIds.contains($0.id) } - return Set(mine.flatMap(\.tagIds)) + return Set(mine.flatMap(\.tagIds)).subtracting(excludedTagIds) } /// Apply search + the speakerFilters chip selection. Same Any/All @@ -65,6 +78,7 @@ struct SpeakersView: View { ) 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) } From 6394116b8f897f267540e44d42b0a60469ea07fb Mon Sep 17 00:00:00 2001 From: Seth Law Date: Sun, 21 Jun 2026 09:13:05 -0600 Subject: [PATCH 08/15] Speakers: precompute tag-id map + drop rogue "Tool" chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Perf `tagIds(for:)` was re-scanning `viewModel.events.filter { ... }` for every speaker on every render — O(speakers × events) per body evaluation. With ~100 speakers × ~1000 events that's 100K scans per keystroke and per chip toggle. Precompute a `[speaker.id: Set]` map in @State, rebuilt only when the underlying lists actually change (via `.task(id: "")`). filteredSpeakers, availableTagTypes, and the chip-pool union all read from the cached map — O(speakers) per render, O(1) lookup per speaker. Also exposed `InfoViewModel.eventsById` as `private(set)` so the chip-rollup in `SpeakerRow` can do O(1) event lookups instead of O(speaker.eventIds × all events) `viewModel.events.filter` scans for each visible row. # "Tool" chip cleanup Speakers were sometimes showing a "Tool" chip — either a tagtype labeled "Tool" or a stray tag from a tagtype not displayed by the filter sheet. Two defenses: 1. Added "Tool" to `SpeakerListConfig.excludedTagTypeLabels`. If "Tool" is a tagtype label, it's now dropped at the source. 2. Both the chip rollup (SpeakerRow) and the filter pipeline (SpeakersView) now intersect speaker tag IDs against the SAME eligibility set (browsable + category=="content" + not in excluded labels) the filter sheet uses to render chips. Rogue tag IDs from non-displayed tagtypes can't leak into either surface — chips and filter stay in lock-step. Co-Authored-By: WOZCODE --- hackertracker/Utils/Filters.swift | 4 +- hackertracker/ViewModels/InfoViewModel.swift | 4 +- hackertracker/Views/SpeakerRow.swift | 26 ++++--- hackertracker/Views/SpeakersView.swift | 72 +++++++++++++------- 4 files changed, 70 insertions(+), 36 deletions(-) diff --git a/hackertracker/Utils/Filters.swift b/hackertracker/Utils/Filters.swift index 1fcf23d..dfd9054 100644 --- a/hackertracker/Utils/Filters.swift +++ b/hackertracker/Utils/Filters.swift @@ -38,5 +38,7 @@ enum SpeakerListConfig { /// 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. - static let excludedTagTypeLabels: Set = ["Skill Level", "Modality"] + /// "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/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/SpeakerRow.swift b/hackertracker/Views/SpeakerRow.swift index 4a607b3..06ad842 100644 --- a/hackertracker/Views/SpeakerRow.swift +++ b/hackertracker/Views/SpeakerRow.swift @@ -46,20 +46,26 @@ struct SpeakerRow: View { private static let inlineBioMaxChars = 100 /// Unique tag IDs rolled up across every event this speaker is - /// associated with, minus tagtypes that are intentionally hidden - /// from the speakers list (see `SpeakerListConfig`). Drives the - /// chip strip — gives the user a glanceable signal of what kind - /// of work this speaker does (Event Category, Organizer, etc.) - /// without having to tap into the detail screen. + /// 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 mine = viewModel.events.filter { speaker.eventIds.contains($0.id) } - let all = Set(mine.flatMap(\.tagIds)) - let excluded: Set = Set( + let eligible: Set = Set( viewModel.tagtypes - .filter { SpeakerListConfig.excludedTagTypeLabels.contains($0.label) } + .filter { $0.category == "content" && $0.isBrowsable } + .filter { !SpeakerListConfig.excludedTagTypeLabels.contains($0.label) } .flatMap { $0.tags.map(\.id) } ) - return Array(all.subtracting(excluded)) + let mineTagIds = speaker.eventIds + .compactMap { viewModel.eventsById[$0]?.tagIds } + .flatMap { $0 } + return Array(Set(mineTagIds).intersection(eligible)) } var body: some View { diff --git a/hackertracker/Views/SpeakersView.swift b/hackertracker/Views/SpeakersView.swift index 18a0917..c7391b0 100644 --- a/hackertracker/Views/SpeakersView.swift +++ b/hackertracker/Views/SpeakersView.swift @@ -23,41 +23,58 @@ struct SpeakersView: View { @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) } - /// Tag IDs from tagtypes intentionally hidden from the speakers - /// list (`SpeakerListConfig.excludedTagTypeLabels`). Cached as a - /// computed property so the filter pipeline and the chip-pool - /// computation both pull from the same exclusion set. - private var excludedTagIds: Set { + /// 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 { SpeakerListConfig.excludedTagTypeLabels.contains($0.label) } + .filter { $0.category == "content" && $0.isBrowsable } + .filter { !SpeakerListConfig.excludedTagTypeLabels.contains($0.label) } .flatMap { $0.tags.map(\.id) } ) } - /// Rolls a speaker's events up into their unique tag IDs minus - /// the excluded tagtypes. Same logic as SpeakerRow's chip strip - /// — keep them aligned so the filter selects on exactly what the - /// chips display. - private func tagIds(for speaker: Speaker) -> Set { - let mine = viewModel.events.filter { speaker.eventIds.contains($0.id) } - return Set(mine.flatMap(\.tagIds)).subtracting(excludedTagIds) + /// 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. Same Any/All - /// semantics as the schedule's filter pipeline so behavior reads - /// the same way to a user toggling between tabs. + /// Apply search + the speakerFilters chip selection using the + /// precomputed per-speaker tag-id map. O(speakers) per render + /// instead of O(speakers × events). private var filteredSpeakers: [Speaker] { let searched = speakers.search(text: searchText) let selected = speakerFilters.filters guard !selected.isEmpty else { return searched } return searched.filter { speaker in - let st = tagIds(for: speaker) + let st = speakerTagIdsMap[speaker.id] ?? [] switch filterMatchMode { case .any: return !st.isDisjoint(with: selected) case .all: return selected.isSubset(of: st) @@ -68,14 +85,13 @@ struct SpeakersView: View { /// 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. + /// chips for tags at least one speaker is connected to. Reads + /// from the precomputed map so this is O(speakers) instead of + /// O(speakers × events). private var availableTagTypes: [TagType] { - let speakerTagPool: Set = Set( - speakers - .flatMap { $0.eventIds } - .compactMap { id in viewModel.events.first(where: { $0.id == id })?.tagIds } - .flatMap { $0 } - ) + let speakerTagPool: Set = speakerTagIdsMap.values.reduce(into: Set()) { + $0.formUnion($1) + } return viewModel.tagtypes .filter { $0.category == "content" && $0.isBrowsable } .filter { !SpeakerListConfig.excludedTagTypeLabels.contains($0.label) } @@ -248,6 +264,14 @@ struct SpeakersView: View { 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) From ed828add0fc8bae039f94dca23948e9eabc676cb Mon Sep 17 00:00:00 2001 From: Seth Law Date: Sun, 21 Jun 2026 09:15:06 -0600 Subject: [PATCH 09/15] Chips: single-line horizontal scroll, no more 3-column wrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShowEventCellTags used a LazyVGrid with adaptive 100pt columns, which on typical card widths yielded a 2- or 3-chip grid that wrapped longer labels into multi-row stacks of unequal height ("Hands-on Workshop" jumping to its own row, then half a chip of "Cloud Village CTF" wrapping below, etc.). Switch to a single-row layout: HStack inside a horizontal ScrollView. Every chip is naturally sized to its label width via lineLimit(1) + fixedSize, so chips stay tight to their text. If a row has more chips than fit, the row scrolls horizontally — touch + drag, indicator hidden so it reads as a clean visual rail. Used by EventCell (Schedule), ContentCell (All Content), and SpeakerRow (Speakers list) — same component renders all three. The `minWidth` parameter on the component is retained for source compatibility but is now a no-op; the new layout doesn't need a column width. Co-Authored-By: WOZCODE --- hackertracker/Views/EventCellView.swift | 57 ++++++++++++++++--------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/hackertracker/Views/EventCellView.swift b/hackertracker/Views/EventCellView.swift index 7163155..74f44c2 100644 --- a/hackertracker/Views/EventCellView.swift +++ b/hackertracker/Views/EventCellView.swift @@ -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,47 @@ 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) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + } } From 0127f5c8552577eabac3cdd2f8e1a026f8075142 Mon Sep 17 00:00:00 2001 From: Seth Law Date: Sun, 21 Jun 2026 09:28:54 -0600 Subject: [PATCH 10/15] Speakers: fix blue chips + blank filter sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Blue chips SpeakerRow's NavigationLink wrapper (iPhone path) was missing `.buttonStyle(.plain)`. Without it, NavigationLink tints every child view with the system accent color (system blue) — which painted the chip dots blue regardless of each tag's actual colorBackground. ContentListView + EventsView wrap their NavigationLinks with .buttonStyle(.plain) for exactly this reason. Match them. # Blank filter sheet `availableTagTypes` was reading the precomputed `speakerTagIdsMap`, which rebuilds asynchronously via `.task(id:)`. When the user opens the filter sheet before the map has populated (race during cold load, or a fast tap), the chip pool is empty → no tagtypes render → blank sheet. Decouple: compute the chip pool directly from raw data with `viewModel.eventsById` for O(1) lookups instead of the cached map. This computation only runs when the sheet actually opens, so the O(speakers × avgEvents) cost is one-shot, not per-render — no perf regression. The cached map is still used by `filteredSpeakers` per keystroke / chip toggle, where avoiding the rescan was the actual hot path. Co-Authored-By: WOZCODE --- hackertracker/Views/SpeakersView.swift | 29 ++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/hackertracker/Views/SpeakersView.swift b/hackertracker/Views/SpeakersView.swift index c7391b0..f6fa65c 100644 --- a/hackertracker/Views/SpeakersView.swift +++ b/hackertracker/Views/SpeakersView.swift @@ -85,13 +85,24 @@ struct SpeakersView: View { /// 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. Reads - /// from the precomputed map so this is O(speakers) instead of - /// O(speakers × events). + /// 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 speakerTagPool: Set = speakerTagIdsMap.values.reduce(into: Set()) { - $0.formUnion($1) - } + 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) } @@ -339,6 +350,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) } } } From 97d32a5b598682acf59ccaba73f2fff827afb2a8 Mon Sep 17 00:00:00 2001 From: Seth Law Date: Sun, 21 Jun 2026 09:30:13 -0600 Subject: [PATCH 11/15] Speakers: list event titles under name + title, before chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each speaker row now shows the events they're presenting between the subtitle (job title / short bio / AI summary) and the tag chip strip. Layout: • SF Symbol "calendar" + comma-joined unique event titles • Caption font + gray (matches the chip + AI summary treatment) • lineLimit(2) so a speaker with many talks doesn't blow up the row height Duplicate titles are de-duped (a speaker with two slots of the same "Hands-on Workshop" shouldn't list it twice). Order preserved from `speaker.eventIds`. Whitespace-only titles filtered out. Reads `viewModel.eventsById` for O(speaker.eventIds) lookup — no viewModel.events scan per row. Co-Authored-By: WOZCODE --- hackertracker/Views/SpeakerRow.swift | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/hackertracker/Views/SpeakerRow.swift b/hackertracker/Views/SpeakerRow.swift index 06ad842..7d8f787 100644 --- a/hackertracker/Views/SpeakerRow.swift +++ b/hackertracker/Views/SpeakerRow.swift @@ -45,6 +45,20 @@ struct SpeakerRow: View { /// 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 @@ -115,6 +129,25 @@ struct SpeakerRow: View { .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(.gray) + .padding(.top, 2) + Text(eventNamesLine) + .font(themeManager.captionFont) + .foregroundColor(.gray) + .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 From d7d8fb120379c44aac71e768eb71b4a2746b29de Mon Sep 17 00:00:00 2001 From: Seth Law Date: Sun, 21 Jun 2026 09:40:36 -0600 Subject: [PATCH 12/15] Rows: unify secondary text color across title/AI/events/chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Speaker subtitle, AI summary line, event-titles line, and tag chip labels were all .foregroundColor(.gray) (~50% mid-gray). Reading muted on most surfaces but inconsistent — chip labels sometimes looked dimmer than the subtitle in the same row. New `ThemeColors.muted = Color.primary.opacity(0.75)`: - Brighter than .gray (~75% of primary vs ~50% fixed gray). - Adapts to theme — primary is the system label color, so muted reads lighter on dark themes and darker on light themes. - Still clearly subordinate to the row title (primary). Applied to: - SpeakerRow title / short bio / AI summary sparkle+text / event titles sparkle+text (6 sites) - ChowEventCellTags chip text label (1 site, affects all three list types) - EventCell AI summary sparkle+text (2 sites) - ContentCell AI summary sparkle+text (2 sites) Co-Authored-By: WOZCODE --- hackertracker/Utils/Theme.swift | 6 ++++++ hackertracker/Views/ContentCellView.swift | 4 ++-- hackertracker/Views/EventCellView.swift | 5 +++-- hackertracker/Views/SpeakerRow.swift | 12 ++++++------ 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/hackertracker/Utils/Theme.swift b/hackertracker/Utils/Theme.swift index 489ac6d..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 diff --git a/hackertracker/Views/ContentCellView.swift b/hackertracker/Views/ContentCellView.swift index deec050..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) - .foregroundColor(.gray) + .foregroundColor(ThemeColors.muted) .padding(.top, 2) Text(summary) .font(themeManager.captionFont) - .foregroundColor(.gray) + .foregroundColor(ThemeColors.muted) .lineLimit(2) .multilineTextAlignment(.leading) } diff --git a/hackertracker/Views/EventCellView.swift b/hackertracker/Views/EventCellView.swift index 74f44c2..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) - .foregroundColor(.gray) + .foregroundColor(ThemeColors.muted) .padding(.top, 2) Text(summary) .font(themeManager.captionFont) - .foregroundColor(.gray) + .foregroundColor(ThemeColors.muted) .lineLimit(2) .multilineTextAlignment(.leading) } @@ -260,6 +260,7 @@ struct ShowEventCellTags: View { .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/SpeakerRow.swift b/hackertracker/Views/SpeakerRow.swift index 7d8f787..d92fcae 100644 --- a/hackertracker/Views/SpeakerRow.swift +++ b/hackertracker/Views/SpeakerRow.swift @@ -103,12 +103,12 @@ struct SpeakerRow: View { 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(.gray) + .foregroundColor(ThemeColors.muted) } else if aiBiosEnabled, let summary = TalkSummaryCache.shared.summary(for: speaker) { // AI summary slot — same styling as the talk-cell @@ -118,11 +118,11 @@ struct SpeakerRow: View { HStack(alignment: .top, spacing: 4) { Image(systemName: "sparkles") .font(themeManager.captionFont) - .foregroundColor(.gray) + .foregroundColor(ThemeColors.muted) .padding(.top, 2) Text(summary) .font(themeManager.captionFont) - .foregroundColor(.gray) + .foregroundColor(ThemeColors.muted) .lineLimit(2) .multilineTextAlignment(.leading) } @@ -138,11 +138,11 @@ struct SpeakerRow: View { HStack(alignment: .top, spacing: 4) { Image(systemName: "calendar") .font(themeManager.captionFont) - .foregroundColor(.gray) + .foregroundColor(ThemeColors.muted) .padding(.top, 2) Text(eventNamesLine) .font(themeManager.captionFont) - .foregroundColor(.gray) + .foregroundColor(ThemeColors.muted) .lineLimit(2) .multilineTextAlignment(.leading) } From 7d7a60776b206b97f16a2c56da590c90a5af1096 Mon Sep 17 00:00:00 2001 From: Seth Law Date: Sun, 21 Jun 2026 09:56:16 -0600 Subject: [PATCH 13/15] Speakers: include event titles in search match A user searching the Speakers list for "BadgeLife" should find the speakers presenting that talk even if their name + bio don't mention it. Extend `[Speaker].search(text:)` to also match against the speaker's event titles when caller passes in an `eventsById` lookup. SpeakersView's filteredSpeakers now calls the new overload with viewModel.eventsById. GlobalSearchView still uses the single-arg form since it has its own per-list grouping and shouldn't double- match speakers via talk titles. Co-Authored-By: WOZCODE --- hackertracker/Utils/Searchable.swift | 21 +++++++++++++++++++++ hackertracker/Views/SpeakersView.swift | 6 ++++-- 2 files changed, 25 insertions(+), 2 deletions(-) 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/Views/SpeakersView.swift b/hackertracker/Views/SpeakersView.swift index f6fa65c..9718c6f 100644 --- a/hackertracker/Views/SpeakersView.swift +++ b/hackertracker/Views/SpeakersView.swift @@ -68,9 +68,11 @@ struct SpeakersView: View { /// Apply search + the speakerFilters chip selection using the /// precomputed per-speaker tag-id map. O(speakers) per render - /// instead of O(speakers × events). + /// 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) + let searched = speakers.search(text: searchText, eventsById: viewModel.eventsById) let selected = speakerFilters.filters guard !selected.isEmpty else { return searched } return searched.filter { speaker in From 86186ff021cc81526cc24bfdb5874aeb11116efa Mon Sep 17 00:00:00 2001 From: Seth Law Date: Sun, 21 Jun 2026 10:15:59 -0600 Subject: [PATCH 14/15] Filters: hoist Merch sizes, persist across launches, split match mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merch size selections lived in ProductsView's @State, so they reset on every tab switch. Hoist into a new MerchFiltersStore injected from ContentView (same pattern as Filters / SpeakerFiltersStore) so the selection survives tab switches. All three filter stores (Filters / SpeakerFiltersStore / MerchFiltersStore) now persist via UserDefaults JSON on every @Published change and rehydrate on init, so chip selections survive cold launches. Merch also gets its own filterMatchModeMerch AppStorage key — it was sharing filterMatchMode with Schedule + All Content, which meant toggling Any/All on one list silently flipped it on the others. Co-Authored-By: WOZCODE --- hackertracker/Utils/Filters.swift | 51 ++++++++++++++++++++++++-- hackertracker/Views/ContentView.swift | 7 ++++ hackertracker/Views/ProductsView.swift | 25 +++++++------ 3 files changed, 67 insertions(+), 16 deletions(-) diff --git a/hackertracker/Utils/Filters.swift b/hackertracker/Utils/Filters.swift index dfd9054..91dbd49 100644 --- a/hackertracker/Utils/Filters.swift +++ b/hackertracker/Utils/Filters.swift @@ -7,11 +7,36 @@ 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 } } @@ -21,10 +46,28 @@ class Filters: ObservableObject { /// while `SpeakerFiltersStore` is read only by SpeakersView and the /// speaker filter sheet. final class SpeakerFiltersStore: ObservableObject { - @Published var filters: Set + private static let userDefaultsKey = "filtersStore.speakers.v1" + @Published var filters: Set { + didSet { FilterStorePersistence.save(filters, forKey: Self.userDefaultsKey) } + } init(filters: Set = []) { - self.filters = filters + 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 } } diff --git a/hackertracker/Views/ContentView.swift b/hackertracker/Views/ContentView.swift index 6f7fbde..308e3c8 100644 --- a/hackertracker/Views/ContentView.swift +++ b/hackertracker/Views/ContentView.swift @@ -53,6 +53,10 @@ struct ContentView: View { /// 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 @@ -161,6 +165,7 @@ struct ContentView: View { .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 @@ -187,6 +192,7 @@ struct ContentView: View { .environment(themeManager) .environmentObject(filters) .environmentObject(speakerFilters) + .environmentObject(merchFilters) } else { _04View(message: "Loading", show404: false).preferredColorScheme(theme.colorScheme) .preferredColorScheme(theme.colorScheme) @@ -197,6 +203,7 @@ struct ContentView: View { .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/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 { From 323b03c7692302fb4a4a56c9b15ae7f7b03fe7cb Mon Sep 17 00:00:00 2001 From: Seth Law Date: Fri, 26 Jun 2026 10:12:31 -0600 Subject: [PATCH 15/15] Settings: wrap rows in themed cards matching schedule/content cells Each Settings row was a flat VStack with a trailing Divider. Wrap them in a shared .settingsCard(themeManager) modifier that mirrors the schedule / content cell styling (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. Drops the row Dividers since the cards provide visual separation; folds the Notifications heading, stepper, and "remove all" button into one card. Co-Authored-By: WOZCODE --- hackertracker/Views/SettingsView.swift | 110 ++++++++++++------------- 1 file changed, 54 insertions(+), 56 deletions(-) diff --git a/hackertracker/Views/SettingsView.swift b/hackertracker/Views/SettingsView.swift index 04dafc7..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) } } @@ -825,14 +828,13 @@ struct AISummarySettingsView: View { .foregroundStyle(.secondary) } } - .padding(5) + .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 } } - Divider() } } } @@ -852,8 +854,7 @@ struct ShowCustomEventsSettingsView: View { .font(themeManager.captionFont) .foregroundStyle(.secondary) } - .padding(5) - Divider() + .settingsCard(themeManager) } } @@ -876,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 {