diff --git a/Jarvis/Connectivity/ConnectivityManager.swift b/Jarvis/Connectivity/ConnectivityManager.swift index f0ebf9a..f4ffb1b 100644 --- a/Jarvis/Connectivity/ConnectivityManager.swift +++ b/Jarvis/Connectivity/ConnectivityManager.swift @@ -106,6 +106,8 @@ final class ConnectivityManager: ObservableObject { guard host != lastActivated else { return } // avoid thrashing the WS lastActivated = host await ServerSettings.shared.activate(host: host) - await StoryStore.shared.loadStories(refresh: true) + // Full paginated load — page 1 only misses unread stories on pages 2–5 + // that may have arrived since last session. + await StoryStore.shared.loadFeedFull() } } diff --git a/Jarvis/Info.plist b/Jarvis/Info.plist index 1df4112..6f4c945 100644 --- a/Jarvis/Info.plist +++ b/Jarvis/Info.plist @@ -5,6 +5,7 @@ BGTaskSchedulerPermittedIdentifiers com.kisani.jarvis.briefing.refresh + com.kisani.jarvis.feed.refresh CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) @@ -53,7 +54,5 @@ UIInterfaceOrientationPortrait - UIUserInterfaceStyle - Dark diff --git a/Jarvis/JarvisApp.swift b/Jarvis/JarvisApp.swift index dd76a78..a5e3372 100644 --- a/Jarvis/JarvisApp.swift +++ b/Jarvis/JarvisApp.swift @@ -118,6 +118,9 @@ struct JarvisApp: App { .task { BackgroundRefreshManager.container = container BackgroundRefreshManager.scheduleFeedRefresh() + // Pre-populate the feed from SwiftData cache before the first API + // fetch completes — prevents the cold-launch empty state. + store.bootstrap(container: container) connManager.start() await connManager.resolveAndActivate() await notifications.bootstrap() @@ -127,10 +130,13 @@ struct JarvisApp: App { switch phase { case .active: store.startForegroundSync() - // Refresh immediately if we've been away more than 2 minutes. - if let last = store.lastSyncedAt, - Date().timeIntervalSince(last) > 120 { - Task { await store.loadStories(refresh: true) } + // Only trigger a load if connectivity has already been resolved. + // On cold launch activeHost is nil until resolveAndActivate() + // completes — calling loadFeedFull() before that always throws + // APIError.notConnected and leaves lastSyncedAt permanently nil. + // activate() calls loadFeedFull() itself once the host is confirmed. + if connManager.activeHost != nil { + Task { await store.loadFeedFull() } } case .background: store.stopForegroundSync() diff --git a/Jarvis/Models/Models.swift b/Jarvis/Models/Models.swift index 1b1a64b..0f50bd1 100644 --- a/Jarvis/Models/Models.swift +++ b/Jarvis/Models/Models.swift @@ -178,6 +178,9 @@ final class CachedStory { var consensus: String? var conflict: String? var updatedAt: Date + /// Original publish timestamp. Defaults to updatedAt for rows cached before + /// this field was added — SwiftData handles the automatic migration. + var createdAt: Date = Date() var cachedAt: Date init(from summary: StorySummary) { @@ -191,6 +194,7 @@ final class CachedStory { self.consensus = summary.consensus self.conflict = summary.conflict self.updatedAt = summary.updatedAt + self.createdAt = summary.createdAt self.cachedAt = Date() } } diff --git a/Jarvis/Networking/APIClient.swift b/Jarvis/Networking/APIClient.swift index 8824a61..4b3b69e 100644 --- a/Jarvis/Networking/APIClient.swift +++ b/Jarvis/Networking/APIClient.swift @@ -68,12 +68,14 @@ actor APIClient { func fetchStories( cursor: String? = nil, topic: String? = nil, + tags: Set = [], minSignal: Int? = nil, limit: Int = 20 ) async throws -> PaginatedStories { var params: [String: String] = ["limit": "\(limit)"] if let cursor { params["after"] = cursor } if let topic { params["topic"] = topic } + if !tags.isEmpty { params["tags"] = tags.sorted().joined(separator: ",") } if let minSignal { params["min_signal"] = "\(minSignal)" } return try await get("/stories", params: params) } diff --git a/Jarvis/Networking/WebSocketManager.swift b/Jarvis/Networking/WebSocketManager.swift index 7560c4d..820c9c6 100644 --- a/Jarvis/Networking/WebSocketManager.swift +++ b/Jarvis/Networking/WebSocketManager.swift @@ -5,7 +5,7 @@ import Foundation import Combine enum ConnectionState { - case disconnected + case disconnected case connecting case connected case reconnecting(attempt: Int) diff --git a/Jarvis/Notifications/NotificationManager.swift b/Jarvis/Notifications/NotificationManager.swift index 049f0cb..b053fc2 100644 --- a/Jarvis/Notifications/NotificationManager.swift +++ b/Jarvis/Notifications/NotificationManager.swift @@ -41,6 +41,11 @@ final class NotificationManager: NSObject, ObservableObject { func bootstrap() async { center.delegate = self await refreshAuthStatus() + // Prompt for permission on first launch (.notDetermined). + // Re-requesting after denial is a no-op (iOS ignores it) — safe to call every launch. + if authStatus == .notDetermined { + await requestAuthorization() + } subscribeToBreakingEvents() await applySettings() } diff --git a/Jarvis/Notifications/NotificationSettings.swift b/Jarvis/Notifications/NotificationSettings.swift index af47cd8..a068f6b 100644 --- a/Jarvis/Notifications/NotificationSettings.swift +++ b/Jarvis/Notifications/NotificationSettings.swift @@ -51,8 +51,8 @@ final class NotificationSettings: ObservableObject { weatherEnabled = s.weatherEnabled; locationName = s.locationName latitude = s.latitude; longitude = s.longitude; resolvedPlace = s.resolvedPlace } else { - enabled = false - breakingEnabled = true; breakingMinSignal = 80 + enabled = true + breakingEnabled = true; breakingMinSignal = 65 morningEnabled = true; morningHour = 7; morningMinute = 0 eveningEnabled = true; eveningHour = 18; eveningMinute = 0 weatherEnabled = true; locationName = "" diff --git a/Jarvis/Store/CacheMaintenance.swift b/Jarvis/Store/CacheMaintenance.swift index 00b894b..74d4fc2 100644 --- a/Jarvis/Store/CacheMaintenance.swift +++ b/Jarvis/Store/CacheMaintenance.swift @@ -10,11 +10,8 @@ enum CacheMaintenance { private static let hour: TimeInterval = 3_600 private static let day: TimeInterval = 86_400 - // Aligned to the backend's aging model: - // RECENT_HOURS = 72 → active feed window; drop cached stories past it - // raw-article retention ~14 days → match for cached article bodies - private static let recentHours = 72.0 // backend active window (story list) - private static let markerHours = 96.0 // seen/read markers: active window + buffer + private static let recentHours = 84.0 // 3.5 days: survives weekends + private static let markerHours = 96.0 // 4 days: active window + buffer private static let articleDays = 14.0 // cached article bodies (backend article retention) /// Age-based eviction. Run on launch. Cheap; protects saved stories. diff --git a/Jarvis/Store/StoryStore.swift b/Jarvis/Store/StoryStore.swift index cc1cf23..505a14b 100644 --- a/Jarvis/Store/StoryStore.swift +++ b/Jarvis/Store/StoryStore.swift @@ -17,23 +17,90 @@ final class StoryStore: ObservableObject { @Published var hasMore = false @Published var selectedTopic: String? = nil @Published var selectedTags: Set = [] + @Published var sectionSupplement: [String: [StorySummary]] = [:] private var nextCursor: String? private var refreshTask: Task? private var foregroundSyncTask: Task? private var cancellables = Set() + // MARK: - Quick cache (UserDefaults — synchronous, no async gap) + + private let quickCacheKey = "jarvis.quickStories.v1" + private let quickCacheMaxAge: TimeInterval = 84 * 3600 // 3.5 days + + /// Restore the last-known feed from UserDefaults synchronously on init. + /// This runs before the first SwiftUI render so the feed is never blank. + private func restoreQuickCache() { + guard let raw = UserDefaults.standard.data(forKey: quickCacheKey) else { return } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + guard let payload = try? decoder.decode(QuickCachePayload.self, from: raw) else { return } + // Discard if too stale — matches CacheMaintenance window. + guard Date().timeIntervalSince(payload.savedAt) < quickCacheMaxAge else { + UserDefaults.standard.removeObject(forKey: quickCacheKey) + return + } + if !payload.stories.isEmpty { stories = payload.stories } + } + + /// Persist the current feed to UserDefaults so it survives process kills. + private func persistQuickCache() { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let payload = QuickCachePayload(stories: Array(stories.prefix(100)), savedAt: Date()) + if let data = try? encoder.encode(payload) { + UserDefaults.standard.set(data, forKey: quickCacheKey) + } + } + + private struct QuickCachePayload: Codable { + let stories: [StorySummary] + let savedAt: Date + } + + // MARK: - SwiftData bootstrap (secondary, async — covers first-ever launch before + // the quick cache exists, e.g. app reinstall). + func bootstrap(container: ModelContainer) { + guard stories.isEmpty else { return } + let ctx = ModelContext(container) + if let cached = try? ctx.fetch(FetchDescriptor()) { + let sorted = cached.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder) + if !sorted.isEmpty { stories = sorted } + } + } + // MARK: - Foreground sync - /// Call when app becomes active. Polls every 5 min so the feed never runs dry. - /// The WebSocket handles real-time events between polls. + private var isFetchingFull = false + + /// Load page 1 then paginate to ≤100 stories. + /// Guarded against concurrent invocations and rate-limited to ≥30 s between + /// fetches so rapid foreground/background cycles don't hammer the API. + func loadFeedFull() async { + guard !isFetchingFull else { return } + // Rate-limit: if the last *successful* sync was < 30 s ago, skip. + if let last = lastSyncedAt, Date().timeIntervalSince(last) < 30 { return } + isFetchingFull = true + defer { isFetchingFull = false } + + await loadStories(refresh: true) + var pages = 0 + while !Task.isCancelled, hasMore, stories.count < 100, pages < 4 { + await loadMore() + pages += 1 + } + loadSectionSupplements() + } + + /// Call when app becomes active. Polls every 90 s so new stories surface fast. func startForegroundSync() { foregroundSyncTask?.cancel() foregroundSyncTask = Task { [weak self] in while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 5 * 60 * 1_000_000_000) + try? await Task.sleep(nanoseconds: 90 * 1_000_000_000) guard !Task.isCancelled else { return } - await self?.loadStories(refresh: true) + await self?.loadFeedFull() } } } @@ -46,6 +113,7 @@ final class StoryStore: ObservableObject { private let api = APIClient.shared private init() { + restoreQuickCache() // synchronous — stories are ready before first render subscribeToWebSocket() } @@ -67,20 +135,28 @@ final class StoryStore: ObservableObject { tags: selectedTags ) if refresh { + // Replace list; count only genuinely new IDs for reshuffle logic. let existingIds = Set(stories.map(\.id)) newCount = result.data.filter { !existingIds.contains($0.id) }.count stories = result.data } else { - stories += result.data - newCount = result.data.count + // Append only IDs not already present — cursor can overlap. + let existingIds = Set(stories.map(\.id)) + let novel = result.data.filter { !existingIds.contains($0.id) } + stories += novel + newCount = novel.count } nextCursor = result.nextCursor hasMore = result.hasMore lastSyncedAt = Date() + persistQuickCache() // survives the next process kill preCacheArticlesInBackground() } catch let e as APIError where !e.isCancelled { error = e - } catch {} + print("[StoryStore] fetch failed: \(e.localizedDescription)") + } catch { + print("[StoryStore] unexpected fetch error: \(error)") + } isLoading = false return newCount @@ -105,35 +181,70 @@ final class StoryStore: ObservableObject { isLoadingMore = true do { let result = try await api.fetchStories(cursor: cursor, topic: selectedTopic, tags: selectedTags) - stories += result.data + // Deduplicate: cursor-based pages can overlap when stories are added mid-fetch. + let existingIds = Set(stories.map(\.id)) + let novel = result.data.filter { !existingIds.contains($0.id) } + stories += novel nextCursor = result.nextCursor hasMore = result.hasMore - } catch {} + } catch { + print("[StoryStore] loadMore error: \(error)") + } isLoadingMore = false } - /// Coalesce bursts of `story.created` events into a single refresh so a busy - /// feed (47 sources) doesn't fire a refetch per story. + /// Coalesce bursts of `story.created` WS events — waits 3 s then does a + /// full paginated load so stories on page 2+ are captured. private func scheduleCoalescedRefresh() { refreshTask?.cancel() refreshTask = Task { [weak self] in try? await Task.sleep(nanoseconds: 3_000_000_000) guard !Task.isCancelled else { return } - await self?.loadStories(refresh: true) + await self?.loadFeedFull() + } + } + + // MARK: - Section supplements + // Sections with regional or niche tags rarely rank in the global top-100, + // so we fetch a small targeted pool for each sparse section and let + // makeDigest() use it as a fallback. + + private let supplementSections: [(id: String, tags: Set)] = [ + ("east-africa", ["east-africa"]), + ("africa", ["africa"]), + ("sport", ["sports", "esports", "formula-1"]), + ("science", ["science", "astronomy"]), + ("health", ["health", "health-and-wellness"]), + ] + + func loadSectionSupplements() { + guard selectedTags.isEmpty else { return } // only for "All" pill + for sec in supplementSections { + let id = sec.id + let tags = sec.tags + Task { [weak self] in + guard let self else { return } + if let result = try? await api.fetchStories(tags: tags, limit: 8) { + await MainActor.run { self.sectionSupplement[id] = result.data } + } + } } } func setTopic(_ topic: String?) async { selectedTopic = topic selectedTags = [] - await loadStories(refresh: true) + await loadFeedFull() } func setPill(_ pill: StoryPill) async { selectedTopic = nil selectedTags = pill.slugs nextCursor = nil - await loadStories(refresh: true) + // Filter changed — must fetch regardless of rate-limit, otherwise + // the old stories stay in place and the new pill finds almost nothing. + lastSyncedAt = nil + await loadFeedFull() } // MARK: - WebSocket diff --git a/Jarvis/Views/Connectivity/ConnectivityView.swift b/Jarvis/Views/Connectivity/ConnectivityView.swift index 16c0d14..b57ed0a 100644 --- a/Jarvis/Views/Connectivity/ConnectivityView.swift +++ b/Jarvis/Views/Connectivity/ConnectivityView.swift @@ -20,7 +20,7 @@ struct ConnectivityView: View { var body: some View { ZStack { - Color.black.ignoresSafeArea() + Palette.background.ignoresSafeArea() ScrollView { VStack(alignment: .leading, spacing: 26) { header @@ -44,7 +44,7 @@ struct ConnectivityView: View { HStack { Text("Connectivity") .font(.system(size: 22, weight: .heavy)) - .foregroundStyle(.white) + .foregroundStyle(Palette.primaryText) Spacer() Button { dismiss() } label: { Image(systemName: "xmark") @@ -84,7 +84,7 @@ struct ConnectivityView: View { VStack(alignment: .leading, spacing: 3) { Text("Use \(providerName) when remote") .font(.system(size: 16, weight: .heavy)) - .foregroundStyle(.white) + .foregroundStyle(Palette.primaryText) Text("Connects via VPN when not on home network") .font(.system(size: 12)) .foregroundStyle(Color(hex: "888888")) @@ -160,7 +160,7 @@ struct ConnectivityView: View { VStack(alignment: .leading, spacing: 3) { Text("Auto-connect on network change") .font(.system(size: 16, weight: .heavy)) - .foregroundStyle(.white) + .foregroundStyle(Palette.primaryText) Text("Prefers the LAN on a trusted network; switches to \(providerName) when away") .font(.system(size: 12)) .foregroundStyle(Color(hex: "888888")) @@ -175,7 +175,7 @@ struct ConnectivityView: View { HStack { Text("Wait before switching") .font(.system(size: 15, weight: .bold)) - .foregroundStyle(.white) + .foregroundStyle(Palette.primaryText) Spacer() Stepper(value: $connectivity.switchDelaySeconds, in: 1...60, step: 1) { Text("\(connectivity.switchDelaySeconds)s") @@ -195,7 +195,7 @@ struct ConnectivityView: View { } Text("iOS can't start the \(providerName) tunnel for Jarvis — keep the \(providerName) app connected when away. Jarvis only picks the reachable address.") .font(.system(size: 11)) - .foregroundStyle(Color(hex: "666666")) + .foregroundStyle(Palette.tertiaryText) .lineSpacing(2) } } @@ -231,7 +231,7 @@ struct ConnectivityView: View { Button { Task { await recheck() } } label: { Text("Re-check") .font(.system(size: 15, weight: .bold)) - .foregroundStyle(.white) + .foregroundStyle(Palette.primaryText) .frame(maxWidth: .infinity).frame(height: 50) .background(Palette.surface2) .clipShape(RoundedRectangle(cornerRadius: 14)) @@ -254,7 +254,7 @@ struct ConnectivityView: View { Text(t) .font(.system(size: 11, weight: .bold, design: .monospaced)) .kerning(0.8) - .foregroundStyle(Color(hex: "555555")) + .foregroundStyle(Palette.tertiaryText) } private func Card(@ViewBuilder _ content: () -> Content) -> some View { @@ -267,7 +267,7 @@ struct ConnectivityView: View { HStack(alignment: .center, spacing: 14) { Image(systemName: icon) .font(.system(size: 18, weight: .regular)) - .foregroundStyle(Color(hex: "999999")) + .foregroundStyle(Palette.secondaryText) .frame(width: 26) content() } @@ -287,7 +287,7 @@ struct ConnectivityView: View { .foregroundStyle(Color(hex: "888888")) HStack(spacing: 8) { TextField("", text: text, - prompt: Text(placeholder).foregroundColor(Color(hex: "444444"))) + prompt: Text(placeholder).foregroundColor(Palette.mutedText)) .font(.system(size: 16, weight: .regular, design: .monospaced)) .foregroundStyle(Palette.orange) .autocorrectionDisabled() @@ -312,7 +312,7 @@ struct ConnectivityView: View { case .reachable: return Color(hex: "33C25E") case .unreachable: return Color(hex: "C25555") case .checking: return Palette.healthFailing - case .unknown: return Color(hex: "555555") + case .unknown: return Palette.tertiaryText } } private func dot(for r: Reachability) -> some View { diff --git a/Jarvis/Views/Feeds/AddFeedSheet.swift b/Jarvis/Views/Feeds/AddFeedSheet.swift index f5acb0f..be42bae 100644 --- a/Jarvis/Views/Feeds/AddFeedSheet.swift +++ b/Jarvis/Views/Feeds/AddFeedSheet.swift @@ -21,12 +21,12 @@ struct AddFeedSheet: View { var body: some View { ZStack { - Color.black.ignoresSafeArea() + Palette.background.ignoresSafeArea() VStack(alignment: .leading, spacing: 0) { HStack { Text("Add feed") .font(.system(size: 22, weight: .heavy)) - .foregroundStyle(.white) + .foregroundStyle(Palette.primaryText) Spacer() Button { dismiss() } label: { Image(systemName: "xmark") @@ -87,9 +87,9 @@ struct AddFeedSheet: View { Text(label) .font(.system(size: 10, weight: .bold, design: .monospaced)) .kerning(0.8) - .foregroundStyle(Color(hex: "666666")) + .foregroundStyle(Palette.tertiaryText) TextField("", text: text, - prompt: Text(placeholder).foregroundColor(Color(hex: "444444"))) + prompt: Text(placeholder).foregroundColor(Palette.mutedText)) .font(.system(size: 15, weight: .regular, design: mono ? .monospaced : .default)) .foregroundStyle(mono ? Palette.orange : .white) .autocorrectionDisabled() diff --git a/Jarvis/Views/Feeds/FeedManagerView.swift b/Jarvis/Views/Feeds/FeedManagerView.swift index aca8720..8294772 100644 --- a/Jarvis/Views/Feeds/FeedManagerView.swift +++ b/Jarvis/Views/Feeds/FeedManagerView.swift @@ -86,7 +86,7 @@ struct FeedManagerView: View { var body: some View { ZStack { - Color.black.ignoresSafeArea() + Palette.background.ignoresSafeArea() VStack(spacing: 0) { header serverCard @@ -141,7 +141,7 @@ struct FeedManagerView: View { Text("PLATFORM") .font(.system(size: 9, weight: .bold, design: .monospaced)) .kerning(0.8) - .foregroundStyle(Color(hex: "555555")) + .foregroundStyle(Palette.tertiaryText) Text(settings.host ?? "not configured") .font(.system(size: 14, weight: .bold, design: .monospaced)) .foregroundStyle(Palette.orange) @@ -156,7 +156,7 @@ struct FeedManagerView: View { } Image(systemName: "chevron.right") .font(.system(size: 12, weight: .bold)) - .foregroundStyle(Color(hex: "555555")) + .foregroundStyle(Palette.tertiaryText) } .padding(14) .background(Palette.surface) @@ -173,10 +173,10 @@ struct FeedManagerView: View { HStack(spacing: 10) { Image(systemName: "magnifyingglass") .font(.system(size: 14)) - .foregroundStyle(Color(hex: "555555")) - TextField("", text: $query, prompt: Text("Search feeds").foregroundColor(Color(hex: "555555"))) + .foregroundStyle(Palette.tertiaryText) + TextField("", text: $query, prompt: Text("Search feeds").foregroundColor(Palette.tertiaryText)) .font(.system(size: 15, weight: .regular)) - .foregroundStyle(.white) + .foregroundStyle(Palette.primaryText) .autocorrectionDisabled() .textInputAutocapitalization(.never) } @@ -207,7 +207,7 @@ struct FeedManagerView: View { } .listStyle(.plain) .scrollContentBackground(.hidden) - .background(Color.black) + .background(Palette.background) .refreshable { await vm.load() } } @@ -215,7 +215,7 @@ struct FeedManagerView: View { Section { ForEach(feeds) { feed in FeedRowView(feed: feed) - .listRowBackground(Color.black) + .listRowBackground(Palette.background) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) .listRowSeparatorTint(Palette.hairline) .swipeActions(edge: .trailing) { @@ -230,7 +230,7 @@ struct FeedManagerView: View { Text(title) .font(.system(size: 10, weight: .bold, design: .monospaced)) .kerning(0.8) - .foregroundStyle(Color(hex: "555555")) + .foregroundStyle(Palette.tertiaryText) .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 6, trailing: 16)) } } @@ -238,16 +238,16 @@ struct FeedManagerView: View { private var loadingRow: some View { ProgressView().tint(Palette.orange) .frame(maxWidth: .infinity) - .listRowBackground(Color.black) + .listRowBackground(Palette.background) .listRowSeparator(.hidden) } private var emptyRow: some View { Text(query.isEmpty ? (vm.error ?? "No feeds configured.") : "No feeds match “\(query)”.") .font(.system(size: 13)) - .foregroundStyle(Color(hex: "555555")) + .foregroundStyle(Palette.tertiaryText) .frame(maxWidth: .infinity, alignment: .leading) - .listRowBackground(Color.black) + .listRowBackground(Palette.background) .listRowSeparator(.hidden) } } @@ -267,11 +267,11 @@ struct FeedRowView: View { VStack(alignment: .leading, spacing: 4) { Text(feed.name) .font(.system(size: 15, weight: .bold)) - .foregroundStyle(.white) + .foregroundStyle(Palette.primaryText) .lineLimit(1) Text(metaLine) .font(.system(size: 11, weight: .regular, design: .monospaced)) - .foregroundStyle(Color(hex: "666666")) + .foregroundStyle(Palette.tertiaryText) .lineLimit(1) } diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index 883aa6d..ff5f635 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -33,6 +33,8 @@ struct SignalFeedView: View { /// Live stories when connected; cached fallback while loading or offline so /// switching to Tech / AI / F1 shows cached cards instantly. + /// Before the first sync of this session completes, only unread stories are + /// surfaced — read content is irrelevant until we know there's nothing newer. private var filteredStories: [StorySummary] { let base: [StorySummary] if !store.stories.isEmpty { @@ -40,7 +42,11 @@ struct SignalFeedView: View { } else { base = cachedStories.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder) } - return base.filter { $0.matches(selectedPill) } + let matched = base.filter { $0.matches(selectedPill) } + if store.lastSyncedAt == nil { + return matched.filter { !readStoryIds.contains($0.id) } + } + return matched } /// The main feed: unread stories, with already-seen ones sunk below fresh ones. @@ -59,11 +65,14 @@ struct SignalFeedView: View { /// Every pill shows the card layout — sections are scoped to "All". private var isDigest: Bool { true } - /// Front-page digest: Top Stories + up to 3 per category section, deduped. + /// Front-page digest: Top Stories + up to 4 per category section, deduped. + /// Sections that score poorly in the global top-100 are supplemented from + /// per-section targeted fetches held in store.sectionSupplement. private func makeDigest(sections: [NewsSection]) -> (top: [StorySummary], sections: [(NewsSection, [StorySummary])]) { let unread = filteredStories.filter { !readStoryIds.contains($0.id) } let top = Array(unread.prefix(5)) var pool = Array(unread.dropFirst(5)) + var usedIds = Set(top.map(\.id)) var out: [(NewsSection, [StorySummary])] = [] for section in sections { var picked: [StorySummary] = [], rest: [StorySummary] = [] @@ -72,6 +81,15 @@ struct SignalFeedView: View { else { rest.append(s) } } pool = rest + // Supplement from section-specific fetch when main pool is sparse + if picked.count < 2, let extra = store.sectionSupplement[section.id] { + for s in extra where !usedIds.contains(s.id) && s.inSection(section) { + picked.append(s) + usedIds.insert(s.id) + if picked.count == 4 { break } + } + } + usedIds.formUnion(picked.map(\.id)) if !picked.isEmpty { out.append((section, picked)) } } return (top, out) @@ -102,7 +120,7 @@ struct SignalFeedView: View { var body: some View { NavigationStack { ZStack { - Color.black.ignoresSafeArea() + Palette.background.ignoresSafeArea() VStack(spacing: 0) { header @@ -134,39 +152,35 @@ struct SignalFeedView: View { } .task { CacheMaintenance.prune(modelContext) } // bound the caches on launch .task(id: selectedPill) { - await store.setPill(selectedPill) - var pages = 0 - // All and Tech both use multi-section layouts that need enough stories. - if selectedPill == .all || selectedPill == .tech { - while store.stories.count < 100 && store.hasMore && pages < 5 { - await store.loadMore(); pages += 1 - } - } + await store.setPill(selectedPill) // paginates to 100 stories internally } } // MARK: - Header private var header: some View { - HStack(alignment: .center) { - JarvisWordmark(size: 30) + HStack(alignment: .center, spacing: 0) { + JarvisWordmark(size: 26) Spacer() - ConnectionBanner(state: ws.connectionState) - Button { - showFeeds = true - } label: { - Image(systemName: "dot.radiowaves.up.forward") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(Color(hex: "888888")) - .frame(width: 44, height: 44) - } - Button { - showSettings = true - } label: { - Image(systemName: "gearshape") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(Color(hex: "888888")) - .frame(width: 44, height: 44) + // Connection + controls as one coherent surface + HStack(spacing: 2) { + ConnectionBanner(state: ws.connectionState) + Button { + showFeeds = true + } label: { + Image(systemName: "dot.radiowaves.up.forward") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Palette.secondaryText) + .frame(width: 40, height: 40) + } + Button { + showSettings = true + } label: { + Image(systemName: "gearshape") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Palette.secondaryText) + .frame(width: 40, height: 40) + } } } .padding(.horizontal, 16) @@ -175,18 +189,33 @@ struct SignalFeedView: View { // MARK: - Sync bar + /// True while the first sync of this session hasn't completed yet, OR while + /// a fetch is actively in flight. Used to gate the "caught up" state so we + /// never declare victory before we've actually checked the server. + private var isSyncing: Bool { store.isLoading || store.lastSyncedAt == nil } + private var syncBar: some View { - Text(syncText) - .font(.system(size: 11, weight: .regular, design: .monospaced)) - .foregroundStyle(Color(hex: "5A5A5A")) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.bottom, 12) + HStack(spacing: 5) { + if isSyncing { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.mini) + .tint(Palette.orange) + } + Text(isSyncing ? "Syncing signals…" : syncText) + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(Color(hex: "5A5A5A")) + .animation(.easeInOut(duration: 0.15), value: store.isLoading) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.bottom, 12) } private var syncText: String { - let ago = syncedMinutesAgo(store.lastSyncedAt) - let phrase = (ago == "just now" || ago == "never") ? ago : "\(ago) ago" + guard let last = store.lastSyncedAt else { return "Loading from cache…" } + let ago = last.timeAgoShort() + let phrase = ago == "just now" ? ago : "\(ago) ago" if ws.connectionState.isLive { return "Synced \(phrase) · \(cachedStoryIds.count) cached" } else { @@ -213,10 +242,10 @@ struct SignalFeedView: View { private func pill(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { Text(label) - .font(.system(size: 13, weight: .bold)) - .foregroundStyle(selected ? .black : Color(hex: "AAAAAA")) - .padding(.horizontal, 14) - .frame(height: 32) + .font(.system(size: 12, weight: selected ? .semibold : .medium)) + .foregroundStyle(selected ? .black : Palette.secondaryText) + .padding(.horizontal, 13) + .frame(height: 28) .background(selected ? Palette.orange : Palette.surface) .clipShape(Capsule()) } @@ -233,7 +262,7 @@ struct SignalFeedView: View { .kerning(0.8) } .font(.system(size: 10, weight: .bold, design: .monospaced)) - .foregroundStyle(Color(hex: "555555")) + .foregroundStyle(Palette.tertiaryText) .padding(.horizontal, 16) .padding(.bottom, 8) } @@ -246,50 +275,84 @@ struct SignalFeedView: View { } .listStyle(.plain) .scrollContentBackground(.hidden) - .background(Color.black) + .background(Palette.background) .animation(.snappy, value: showRead) .animation(.snappy, value: readStories.count) - .onAppear { captureSeenSnapshot() } + .onAppear { + captureSeenSnapshot() + // If the cache already shows all-read on first render, open the + // shelf immediately so the user sees their previous stories. + if !readItems.isEmpty && mainStories.isEmpty { showRead = true } + } + // Expand shelf when loading finishes and there are still no unread stories. + .onChange(of: store.isLoading) { _, loading in + if !loading, !readItems.isEmpty, mainStories.isEmpty { + withAnimation(.snappy) { showRead = true } + } + } + // Expand shelf if SwiftData @Query hydrates after the first render and + // all cached stories turn out to already be read. + .onChange(of: readItems.count) { _, count in + if count > 0, mainStories.isEmpty, !showRead { + showRead = true + } + } .refreshable { seenSnapshot = [] seenTracker.ids = [] - let new = await store.loadStories(refresh: true) - // Nothing new from server — pull in any stories cached by background - // refresh that aren't already in the live feed, then reshuffle. - if new == 0 { - let liveIds = Set(store.stories.map(\.id)) - let extra = cachedStories - .filter { !liveIds.contains($0.id) } - .map(StorySummary.init(cached:)) - if !extra.isEmpty { - await store.mergeStories(extra) - } + await store.loadFeedFull() + // Merge any stories cached by background refresh that aren't in + // the live feed yet — mergeStories deduplicates, so always safe. + let liveIds = Set(store.stories.map(\.id)) + let extra = cachedStories + .filter { !liveIds.contains($0.id) } + .map(StorySummary.init(cached:)) + if !extra.isEmpty { + await store.mergeStories(extra) } } } - /// Card layout for every pill. "All" gets full multi-section front page; - /// topic pills get a hero card + flat list within that topic. + /// Card layout for every pill. + /// "All" → multi-section digest. Tech (sub-sections) → sub-section digest. + /// Everything else → hero card + flat scroll of all stories for that pill. @ViewBuilder private var digestContent: some View { - let activeSections = selectedPill == .all - ? NewsSection.sections - : (selectedPill.subSections ?? []) - let d = makeDigest(sections: activeSections) - let headerLabel = selectedPill == .all ? "TOP STORIES" : selectedPill.label.uppercased() + if selectedPill == .all { + let d = makeDigest(sections: NewsSection.sections) + sectionHeader("TOP STORIES", pill: nil) + pillContent(top: d.top) + ForEach(d.sections, id: \.0.id) { section, stories in + sectionHeader(section.label.uppercased(), pill: section.pill) + ForEach(stories) { mainRow($0) } + } + } else if let subSections = selectedPill.subSections { + let d = makeDigest(sections: subSections) + sectionHeader(selectedPill.label.uppercased(), pill: nil) + pillContent(top: d.top) + ForEach(d.sections, id: \.0.id) { section, stories in + sectionHeader(section.label.uppercased(), pill: section.pill) + ForEach(stories) { mainRow($0) } + } + } else { + // Flat list: hero + all stories for this pill + sectionHeader(selectedPill.label.uppercased(), pill: nil) + pillContent(top: mainStories, flat: true) + } + readShelf + } - sectionHeader(headerLabel, pill: nil) - if let lead = d.top.first { + /// Shared hero + body rows. `flat` = render all items; otherwise only top 5. + @ViewBuilder private func pillContent(top stories: [StorySummary], flat: Bool = false) -> some View { + if let lead = stories.first { mainRow(lead, hero: true) - ForEach(d.top.dropFirst()) { mainRow($0) } + let rest = flat ? Array(stories.dropFirst()) : Array(stories.dropFirst().prefix(4)) + ForEach(rest) { mainRow($0) } + } else if !filteredStories.isEmpty { + if isSyncing { checkingRow.plainBlackRow() } + else { caughtUpRow.plainBlackRow() } } else { emptyState.plainBlackRow() } - // "All" and pills with sub-sections (Tech) get a sectioned breakdown. - ForEach(d.sections, id: \.0.id) { section, stories in - sectionHeader(section.label.uppercased(), pill: section.pill) - ForEach(stories) { mainRow($0) } - } - readShelf } private func sectionHeader(_ label: String, pill: StoryPill?) -> some View { @@ -297,7 +360,7 @@ struct SignalFeedView: View { Text(label) .font(.system(size: 13, weight: .heavy, design: .monospaced)) .kerning(0.6) - .foregroundStyle(.white) + .foregroundStyle(Palette.primaryText) Rectangle().fill(Palette.orange).frame(width: 18, height: 2) Spacer() if let pill { @@ -326,7 +389,7 @@ struct SignalFeedView: View { } } .listRowInsets(hero ? EdgeInsets(top: 2, leading: 16, bottom: 12, trailing: 16) : EdgeInsets()) - .listRowBackground(Color.black) + .listRowBackground(Palette.background) .listRowSeparator(.hidden) .onAppear { markSeen(story) @@ -358,24 +421,24 @@ struct SignalFeedView: View { HStack(alignment: .top, spacing: 0) { SignalStripe(score: story.signalScore, width: 4) VStack(alignment: .leading, spacing: 11) { - HStack(alignment: .firstTextBaseline) { + HStack(alignment: .center) { Text(Topic.label(story.topic).uppercased()) .font(.system(size: 10, weight: .bold, design: .monospaced)).kerning(0.8) - .foregroundStyle(Color(hex: "8A8A8A")) + .foregroundStyle(Palette.secondaryText) Spacer() Text("\(story.signalScore)") - .font(.system(size: 21, weight: .heavy, design: .monospaced)) + .font(.system(size: 15, weight: .semibold, design: .monospaced)) .foregroundStyle(Signal.scoreColor(story.signalScore)) } Text(story.headline) - .font(.system(size: 24, weight: .heavy)) - .foregroundStyle(.white) + .font(.system(size: 25, weight: .bold, design: .default)) + .foregroundStyle(Palette.primaryText) .lineLimit(4) .fixedSize(horizontal: false, vertical: true) if !story.summary.isEmpty { Text(story.summary) - .font(.system(size: 14)) - .foregroundStyle(Color(hex: "9A9A9A")) + .font(.system(size: 16, weight: .regular, design: .default)) + .foregroundStyle(Palette.bodyText) .lineLimit(3).lineSpacing(2) .fixedSize(horizontal: false, vertical: true) } @@ -384,7 +447,7 @@ struct SignalFeedView: View { } Text("\(story.sourceCount) sources · \(story.updatedAt.timeAgoShort()) ago") .font(.system(size: 11, weight: .regular, design: .monospaced)) - .foregroundStyle(Color(hex: "666666")) + .foregroundStyle(Palette.tertiaryText) } .padding(16) Spacer(minLength: 0) @@ -394,11 +457,27 @@ struct SignalFeedView: View { .overlay(RoundedRectangle(cornerRadius: 16).stroke(Palette.surface2, lineWidth: 0.5)) } + /// Shown when all cached stories are read AND the first sync hasn't finished yet. + /// Keeps the UI honest — we haven't confirmed "no new content" until the server responds. + private var checkingRow: some View { + VStack(spacing: 8) { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.mini) + .tint(Palette.orange) + Text("Checking for new signals…") + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .foregroundStyle(Palette.tertiaryText) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 50) + } + private var caughtUpRow: some View { VStack(spacing: 10) { Image(systemName: "checkmark.circle") .font(.system(size: 32)).foregroundStyle(Color(hex: "3E7E3E")) - Text("You're all caught up").font(.system(size: 16, weight: .heavy)).foregroundStyle(.white) + Text("You're all caught up").font(.system(size: 16, weight: .semibold, design: .default)).foregroundStyle(Palette.primaryText) Text("Pull to refresh for more") .font(.system(size: 12)).foregroundStyle(Color(hex: "888888")) } @@ -423,7 +502,7 @@ struct SignalFeedView: View { Image(systemName: showRead ? "chevron.up" : "chevron.down") .font(.system(size: 11, weight: .bold)) } - .foregroundStyle(Color(hex: "666666")) + .foregroundStyle(Palette.tertiaryText) .padding(.horizontal, 16).padding(.vertical, 16) .contentShape(Rectangle()) } @@ -455,8 +534,8 @@ struct SignalFeedView: View { private var emptyState: some View { VStack(spacing: 12) { - if store.isLoading { - // Actively polling the server. + if isSyncing { + // First sync hasn't completed yet, or a fetch is actively in flight. ProgressView().tint(Palette.orange) Text("Polling signals…") .font(.system(size: 14, weight: .heavy)) @@ -466,7 +545,7 @@ struct SignalFeedView: View { // Reached the empty state because the fetch failed. emptyIcon("exclamationmark.triangle", color: Color(hex: "C25555")) Text("Couldn't reach the server") - .font(.system(size: 15, weight: .heavy)).foregroundStyle(.white) + .font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText) Text(error.errorDescription ?? "Unknown error") .font(.system(size: 12)).foregroundStyle(Color(hex: "888888")) .multilineTextAlignment(.center) @@ -474,14 +553,14 @@ struct SignalFeedView: View { refreshButton("Retry") } else if !ws.connectionState.isLive { emptyIcon("wifi.slash") - Text("Offline").font(.system(size: 15, weight: .heavy)).foregroundStyle(.white) + Text("Offline").font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText) Text("No cached stories to show") .font(.system(size: 12)).foregroundStyle(Color(hex: "888888")) refreshButton("Retry") } else if selectedPill != .all { emptyIcon("line.3.horizontal.decrease.circle") Text("No \(selectedPill.label) stories yet") - .font(.system(size: 15, weight: .heavy)).foregroundStyle(.white) + .font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText) if store.isLoadingMore || store.hasMore { Text("Pulling more to find them…") .font(.system(size: 12)).foregroundStyle(Color(hex: "888888")) @@ -494,7 +573,7 @@ struct SignalFeedView: View { } else { // Connected, loaded, genuinely nothing yet. emptyIcon("antenna.radiowaves.left.and.right") - Text("No signals yet").font(.system(size: 15, weight: .heavy)).foregroundStyle(.white) + Text("No signals yet").font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText) Text("Pull to refresh") .font(.system(size: 12)).foregroundStyle(Color(hex: "888888")) refreshButton("Refresh") @@ -505,7 +584,7 @@ struct SignalFeedView: View { .padding(.top, 80) } - private func emptyIcon(_ name: String, color: Color = Color(hex: "333333")) -> some View { + private func emptyIcon(_ name: String, color: Color = Palette.mutedText) -> some View { Image(systemName: name).font(.system(size: 30)).foregroundStyle(color) } @@ -513,13 +592,13 @@ struct SignalFeedView: View { if let host = settings.host { Text(host) .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(Color(hex: "555555")) + .foregroundStyle(Palette.tertiaryText) } } private func refreshButton(_ title: String) -> some View { Button { - Task { await store.loadStories(refresh: true) } + Task { await store.loadFeedFull() } } label: { Text(title) .font(.system(size: 14, weight: .bold)).foregroundStyle(.black) @@ -611,7 +690,7 @@ extension StorySummary { consensus: cached.consensus, conflict: cached.conflict, updatedAt: cached.updatedAt, - createdAt: cached.updatedAt + createdAt: cached.createdAt ) } } @@ -620,7 +699,7 @@ private extension View { /// Full-bleed black List row with no separator — keeps the flat feed look. func plainBlackRow() -> some View { self.listRowInsets(EdgeInsets()) - .listRowBackground(Color.black) + .listRowBackground(Palette.background) .listRowSeparator(.hidden) } } diff --git a/Jarvis/Views/Home/StoryRowView.swift b/Jarvis/Views/Home/StoryRowView.swift index 4bec2e0..1cf2e9d 100644 --- a/Jarvis/Views/Home/StoryRowView.swift +++ b/Jarvis/Views/Home/StoryRowView.swift @@ -22,8 +22,8 @@ struct StoryRowView: View { HStack(spacing: 0) { SignalStripe(score: story.signalScore, muted: true) Text(story.headline) - .font(.system(size: 14, weight: .bold)) - .foregroundStyle(Color(hex: "6A6A6A")) + .font(.system(size: 15, weight: .semibold, design: .default)) + .foregroundStyle(Palette.secondaryText) .lineLimit(1) .padding(.leading, 14) .padding(.vertical, 11) @@ -34,7 +34,7 @@ struct StoryRowView: View { .padding(.trailing, 16) } .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.black) + .background(Palette.background) .overlay(alignment: .bottom) { Rectangle().fill(Palette.hairline).frame(height: 0.5) } .contentShape(Rectangle()) } @@ -49,54 +49,48 @@ struct StoryRowView: View { } VStack(alignment: .leading, spacing: 9) { - // Title (+ cached dot) - HStack(alignment: .top, spacing: 8) { - Text(story.headline) - .font(.system(size: 17, weight: .heavy)) - .foregroundStyle(Signal.titleColor(story.signalScore)) - .lineLimit(3) - .fixedSize(horizontal: false, vertical: true) + Text(story.headline) + .font(.system(size: 19, weight: .bold, design: .default)) + .foregroundStyle(Signal.titleColor(story.signalScore)) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) - if isCached { - CachedDot() - .padding(.top, 6) - } - } - - // Summary preview — the gist, without opening the story. if !story.summary.isEmpty { Text(story.summary) - .font(.system(size: 13, weight: .regular)) - .foregroundStyle(Color(hex: "8A8A8A")) - .lineLimit(6) - .lineSpacing(2) + .font(.system(size: 15, weight: .regular, design: .default)) + .foregroundStyle(Palette.bodyText) + .lineLimit(5) + .lineSpacing(3) .fixedSize(horizontal: false, vertical: true) } - // Source chips if !sourceNames.isEmpty { SourceChips(names: sourceNames, total: story.sourceCount) } - // Metadata Text(metaLine) .font(.system(size: 11, weight: .regular, design: .monospaced)) - .foregroundStyle(Color(hex: "666666")) + .foregroundStyle(Palette.tertiaryText) } .padding(.leading, 14) .padding(.vertical, 16) Spacer(minLength: 12) - // Signal score column - Text("\(story.signalScore)") - .font(.system(size: 22, weight: .heavy, design: .monospaced)) - .foregroundStyle(Signal.scoreColor(story.signalScore)) - .padding(.trailing, 16) - .padding(.top, 16) + // Signal cluster: score + offline dot, compact and aligned + VStack(alignment: .trailing, spacing: 4) { + Text("\(story.signalScore)") + .font(.system(size: 14, weight: .semibold, design: .monospaced)) + .foregroundStyle(Signal.scoreColor(story.signalScore)) + if isCached { + CachedDot(size: 6) + } + } + .padding(.trailing, 16) + .padding(.top, 18) } .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.black) + .background(Palette.background) .overlay(alignment: .bottom) { Rectangle().fill(Palette.hairline).frame(height: 0.5) } @@ -128,5 +122,5 @@ struct StoryRowView: View { ) } } - .background(Color.black) + .background(Palette.background) } diff --git a/Jarvis/Views/Onboarding/OnboardingView.swift b/Jarvis/Views/Onboarding/OnboardingView.swift index 82ef826..2bbd1e4 100644 --- a/Jarvis/Views/Onboarding/OnboardingView.swift +++ b/Jarvis/Views/Onboarding/OnboardingView.swift @@ -11,7 +11,7 @@ struct OnboardingView: View { var body: some View { ZStack { - Color.black.ignoresSafeArea() + Palette.background.ignoresSafeArea() VStack(alignment: .leading, spacing: 0) { Spacer().frame(height: 48) diff --git a/Jarvis/Views/Reader/ArticleReaderView.swift b/Jarvis/Views/Reader/ArticleReaderView.swift index 2592ac7..b8583bf 100644 --- a/Jarvis/Views/Reader/ArticleReaderView.swift +++ b/Jarvis/Views/Reader/ArticleReaderView.swift @@ -175,7 +175,7 @@ struct ArticleReaderView: View { HStack(spacing: 10) { Text(article.source) .font(.system(size: 11, weight: .bold)) - .foregroundStyle(.white) + .foregroundStyle(Palette.primaryText) .lineLimit(1) .padding(.horizontal, 10).padding(.vertical, 5) .background(Palette.orange) diff --git a/Jarvis/Views/SecondaryTabs.swift b/Jarvis/Views/SecondaryTabs.swift index 6aba1a5..b484819 100644 --- a/Jarvis/Views/SecondaryTabs.swift +++ b/Jarvis/Views/SecondaryTabs.swift @@ -21,7 +21,7 @@ private struct TabHeader: View { JarvisWordmark(size: 26) Text(title) .font(.system(size: 13, weight: .bold)) - .foregroundStyle(Color(hex: "666666")) + .foregroundStyle(Palette.tertiaryText) .padding(.top, 6) Spacer() } @@ -65,7 +65,7 @@ struct LatestView: View { var body: some View { NavigationStack { ZStack { - Color.black.ignoresSafeArea() + Palette.background.ignoresSafeArea() VStack(spacing: 0) { TabHeader(title: "Latest") Divider().overlay(Palette.hairline) @@ -98,20 +98,20 @@ struct SavedView: View { var body: some View { NavigationStack { ZStack { - Color.black.ignoresSafeArea() + Palette.background.ignoresSafeArea() VStack(spacing: 0) { TabHeader(title: "Saved") Divider().overlay(Palette.hairline) if stories.isEmpty { VStack(spacing: 12) { Image(systemName: "bookmark") - .font(.system(size: 28)).foregroundStyle(Color(hex: "333333")) + .font(.system(size: 28)).foregroundStyle(Palette.mutedText) Text("Nothing saved yet") .font(.system(size: 14, weight: .bold)) - .foregroundStyle(Color(hex: "555555")) + .foregroundStyle(Palette.tertiaryText) Text("Swipe a story left to save it") .font(.system(size: 12)) - .foregroundStyle(Color(hex: "444444")) + .foregroundStyle(Palette.mutedText) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { @@ -145,7 +145,7 @@ struct SearchView: View { var body: some View { NavigationStack { ZStack { - Color.black.ignoresSafeArea() + Palette.background.ignoresSafeArea() VStack(spacing: 0) { TabHeader(title: "Search") searchBar @@ -168,11 +168,11 @@ struct SearchView: View { private var searchBar: some View { HStack(spacing: 10) { Image(systemName: "magnifyingglass") - .font(.system(size: 14)).foregroundStyle(Color(hex: "555555")) + .font(.system(size: 14)).foregroundStyle(Palette.tertiaryText) TextField("", text: $query, - prompt: Text("Search").foregroundColor(Color(hex: "555555"))) + prompt: Text("Search").foregroundColor(Palette.tertiaryText)) .font(.system(size: 15)) - .foregroundStyle(.white) + .foregroundStyle(Palette.primaryText) .autocorrectionDisabled() } .padding(.horizontal, 14) @@ -186,7 +186,7 @@ struct SearchView: View { private func placeholder(_ text: String) -> some View { Text(text) .font(.system(size: 14, weight: .bold)) - .foregroundStyle(Color(hex: "555555")) + .foregroundStyle(Palette.tertiaryText) .frame(maxWidth: .infinity, maxHeight: .infinity) } } diff --git a/Jarvis/Views/Settings/NotificationsView.swift b/Jarvis/Views/Settings/NotificationsView.swift index 4027bef..1486208 100644 --- a/Jarvis/Views/Settings/NotificationsView.swift +++ b/Jarvis/Views/Settings/NotificationsView.swift @@ -15,7 +15,7 @@ struct NotificationsView: View { var body: some View { ZStack { - Color.black.ignoresSafeArea() + Palette.background.ignoresSafeArea() ScrollView { VStack(alignment: .leading, spacing: 24) { header @@ -46,7 +46,7 @@ struct NotificationsView: View { // MARK: - Header private var header: some View { - Text("Notifications").font(.system(size: 22, weight: .heavy)).foregroundStyle(.white) + Text("Notifications").font(.system(size: 22, weight: .heavy)).foregroundStyle(Palette.primaryText) .padding(.top, 4) } @@ -78,8 +78,8 @@ struct NotificationsView: View { divider HStack(spacing: 14) { Image(systemName: "dial.medium").font(.system(size: 18)) - .foregroundStyle(Color(hex: "999999")).frame(width: 26) - Text("Only above signal").font(.system(size: 15, weight: .bold)).foregroundStyle(.white) + .foregroundStyle(Palette.secondaryText).frame(width: 26) + Text("Only above signal").font(.system(size: 15, weight: .bold)).foregroundStyle(Palette.primaryText) Spacer() Stepper(value: $settings.breakingMinSignal, in: 0...100, step: 5) {} .labelsHidden().fixedSize() @@ -103,8 +103,8 @@ struct NotificationsView: View { divider HStack(spacing: 14) { Image(systemName: "clock").font(.system(size: 18)) - .foregroundStyle(Color(hex: "999999")).frame(width: 26) - Text("Time").font(.system(size: 15, weight: .bold)).foregroundStyle(.white) + .foregroundStyle(Palette.secondaryText).frame(width: 26) + Text("Time").font(.system(size: 15, weight: .bold)).foregroundStyle(Palette.primaryText) Spacer() DatePicker("", selection: timeBinding(hour: hour, minute: minute), displayedComponents: .hourAndMinute) @@ -142,13 +142,13 @@ struct NotificationsView: View { divider HStack(spacing: 14) { Image(systemName: "mappin.and.ellipse").font(.system(size: 18)) - .foregroundStyle(Color(hex: "999999")).frame(width: 26) + .foregroundStyle(Palette.secondaryText).frame(width: 26) VStack(alignment: .leading, spacing: 3) { Text("Location").font(.system(size: 12)).foregroundStyle(Color(hex: "888888")) TextField("", text: $settings.locationName, - prompt: Text("Kampala").foregroundColor(Color(hex: "444444"))) + prompt: Text("Kampala").foregroundColor(Palette.mutedText)) .font(.system(size: 16, weight: .regular)) - .foregroundStyle(.white) + .foregroundStyle(Palette.primaryText) .autocorrectionDisabled() .onSubmit { resolveLocation() } if let place = settings.resolvedPlace { @@ -180,7 +180,7 @@ struct NotificationsView: View { private var testButton: some View { Button { Task { await manager.sendTestBriefing() } } label: { Text("Send a test briefing") - .font(.system(size: 15, weight: .bold)).foregroundStyle(.white) + .font(.system(size: 15, weight: .bold)).foregroundStyle(Palette.primaryText) .frame(maxWidth: .infinity).frame(height: 50) .background(Palette.surface2).clipShape(RoundedRectangle(cornerRadius: 14)) } @@ -193,7 +193,7 @@ struct NotificationsView: View { private func section(_ label: String, @ViewBuilder _ content: () -> C) -> some View { VStack(alignment: .leading, spacing: 10) { Text(label).font(.system(size: 11, weight: .bold, design: .monospaced)) - .kerning(0.8).foregroundStyle(Color(hex: "555555")) + .kerning(0.8).foregroundStyle(Palette.tertiaryText) VStack(spacing: 0) { content() } .background(Palette.surface).clipShape(RoundedRectangle(cornerRadius: 14)) } @@ -201,10 +201,10 @@ struct NotificationsView: View { private func toggleRow(icon: String, title: String, subtitle: String, isOn: Binding) -> some View { HStack(spacing: 14) { - Image(systemName: icon).font(.system(size: 18)).foregroundStyle(Color(hex: "999999")).frame(width: 26) + Image(systemName: icon).font(.system(size: 18)).foregroundStyle(Palette.secondaryText).frame(width: 26) Toggle(isOn: isOn) { VStack(alignment: .leading, spacing: 3) { - Text(title).font(.system(size: 16, weight: .heavy)).foregroundStyle(.white) + Text(title).font(.system(size: 16, weight: .heavy)).foregroundStyle(Palette.primaryText) Text(subtitle).font(.system(size: 12)).foregroundStyle(Color(hex: "888888")) .fixedSize(horizontal: false, vertical: true) } diff --git a/Jarvis/Views/Settings/SettingsView.swift b/Jarvis/Views/Settings/SettingsView.swift index b806bf6..295ab42 100644 --- a/Jarvis/Views/Settings/SettingsView.swift +++ b/Jarvis/Views/Settings/SettingsView.swift @@ -8,6 +8,7 @@ struct SettingsView: View { @EnvironmentObject var settings: ServerSettings @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext + @AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .dark @Query private var cachedStories: [CachedStory] @Query private var cachedArticles: [CachedArticle] @State private var showClearConfirm = false @@ -15,11 +16,15 @@ struct SettingsView: View { var body: some View { NavigationStack { ZStack { - Color.black.ignoresSafeArea() + Palette.background.ignoresSafeArea() ScrollView { VStack(alignment: .leading, spacing: 26) { header + group("APPEARANCE") { + appearancePicker + } + group("GENERAL") { NavigationLink { NotificationsView() } label: { row(icon: "bell.badge", title: "Notifications", @@ -60,13 +65,47 @@ struct SettingsView: View { } } + private var appearancePicker: some View { + HStack(spacing: 14) { + Image(systemName: "circle.lefthalf.filled") + .font(.system(size: 18)) + .foregroundStyle(Palette.secondaryText) + .frame(width: 26) + Text("Theme") + .font(.system(size: 16, weight: .heavy)) + .foregroundStyle(Palette.primaryText) + Spacer() + HStack(spacing: 0) { + ForEach(AppearanceMode.allCases, id: \.self) { mode in + Button { + appearanceMode = mode + } label: { + Text(mode.label) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(appearanceMode == mode ? .black : Palette.secondaryText) + .padding(.horizontal, 12) + .frame(height: 30) + .background(appearanceMode == mode ? Palette.orange : Color.clear) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + } + .background(Palette.surface2) + .clipShape(Capsule()) + .overlay(Capsule().stroke(Palette.surface2, lineWidth: 0.5)) + } + .padding(.horizontal, 14).padding(.vertical, 14) + .contentShape(Rectangle()) + } + private var header: some View { HStack { - Text("Settings").font(.system(size: 22, weight: .heavy)).foregroundStyle(.white) + Text("Settings").font(.system(size: 22, weight: .heavy)).foregroundStyle(Palette.primaryText) Spacer() Button { dismiss() } label: { Image(systemName: "xmark").font(.system(size: 15, weight: .bold)) - .foregroundStyle(Color(hex: "888888")).frame(width: 44, height: 44) + .foregroundStyle(Palette.secondaryText).frame(width: 44, height: 44) } } .padding(.top, 8) @@ -75,7 +114,7 @@ struct SettingsView: View { private func group(_ label: String, @ViewBuilder _ content: () -> C) -> some View { VStack(alignment: .leading, spacing: 10) { Text(label).font(.system(size: 11, weight: .bold, design: .monospaced)) - .kerning(0.8).foregroundStyle(Color(hex: "555555")) + .kerning(0.8).foregroundStyle(Palette.tertiaryText) VStack(spacing: 0) { content() } .background(Palette.surface) .clipShape(RoundedRectangle(cornerRadius: 14)) @@ -83,18 +122,19 @@ struct SettingsView: View { } private func row(icon: String, title: String, subtitle: String, - chevron: Bool = true, tint: Color = .white) -> some View { - HStack(spacing: 14) { + chevron: Bool = true, tint: Color? = nil) -> some View { + let resolvedTint = tint ?? Palette.primaryText + return HStack(spacing: 14) { Image(systemName: icon).font(.system(size: 18)) - .foregroundStyle(tint == .white ? Color(hex: "999999") : tint).frame(width: 26) + .foregroundStyle(tint == nil ? Palette.secondaryText : resolvedTint).frame(width: 26) VStack(alignment: .leading, spacing: 2) { - Text(title).font(.system(size: 16, weight: .heavy)).foregroundStyle(tint).lineLimit(1) - Text(subtitle).font(.system(size: 12)).foregroundStyle(Color(hex: "888888")).lineLimit(1) + Text(title).font(.system(size: 16, weight: .heavy)).foregroundStyle(resolvedTint).lineLimit(1) + Text(subtitle).font(.system(size: 12)).foregroundStyle(Palette.secondaryText).lineLimit(1) } Spacer() if chevron { Image(systemName: "chevron.right").font(.system(size: 13, weight: .bold)) - .foregroundStyle(Color(hex: "555555")) + .foregroundStyle(Palette.tertiaryText) } } .padding(.horizontal, 14).padding(.vertical, 14).frame(minHeight: 44) diff --git a/Jarvis/Views/Shared/CachedBadge.swift b/Jarvis/Views/Shared/CachedBadge.swift index 1e89caa..680d0c1 100644 --- a/Jarvis/Views/Shared/CachedBadge.swift +++ b/Jarvis/Views/Shared/CachedBadge.swift @@ -39,5 +39,5 @@ struct CachedBadge: View { CachedBadge(text: "All 7 articles cached · available offline") } .padding() - .background(Color.black) + .background(Palette.background) } diff --git a/Jarvis/Views/Shared/ConnectionBanner.swift b/Jarvis/Views/Shared/ConnectionBanner.swift index 7574161..a366561 100644 --- a/Jarvis/Views/Shared/ConnectionBanner.swift +++ b/Jarvis/Views/Shared/ConnectionBanner.swift @@ -23,7 +23,7 @@ extension ConnectionState { case .connected: return Color(hex: "33C25E") case .connecting, .reconnecting: return Palette.healthFailing - case .disconnected: return Color(hex: "555555") + case .disconnected: return Palette.tertiaryText } } } @@ -56,5 +56,5 @@ struct ConnectionBanner: View { ConnectionBanner(state: .disconnected) } .padding() - .background(Color.black) + .background(Palette.background) } diff --git a/Jarvis/Views/Shared/SignalStripe.swift b/Jarvis/Views/Shared/SignalStripe.swift index cfe2071..4689c19 100644 --- a/Jarvis/Views/Shared/SignalStripe.swift +++ b/Jarvis/Views/Shared/SignalStripe.swift @@ -29,5 +29,5 @@ struct SignalStripe: View { } } .padding() - .background(Color.black) + .background(Palette.background) } diff --git a/Jarvis/Views/Shared/SourceChips.swift b/Jarvis/Views/Shared/SourceChips.swift index 7db6033..d386a77 100644 --- a/Jarvis/Views/Shared/SourceChips.swift +++ b/Jarvis/Views/Shared/SourceChips.swift @@ -26,11 +26,11 @@ struct SourceChips: View { private func chip(_ text: String, muted: Bool = false) -> some View { Text(text) - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(muted ? Color(hex: "777777") : Color(hex: "CCCCCC")) - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background(Palette.surface2) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(muted ? Palette.chipMutedText : Palette.chipText) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Palette.chipFill) .clipShape(Capsule()) } } @@ -38,5 +38,5 @@ struct SourceChips: View { #Preview { SourceChips(names: ["Daily Monitor", "NilePost", "The EastAfrican"], total: 7) .padding() - .background(Color.black) + .background(Palette.background) } diff --git a/Jarvis/Views/Shared/Theme.swift b/Jarvis/Views/Shared/Theme.swift index b0ffc03..8691f80 100644 --- a/Jarvis/Views/Shared/Theme.swift +++ b/Jarvis/Views/Shared/Theme.swift @@ -3,6 +3,7 @@ // Every hex value here is from the spec; nothing is invented. import SwiftUI +import UIKit // MARK: - Hex colors @@ -24,28 +25,63 @@ extension Color { } } + +extension UIColor { + convenience init(hex: String) { + let s = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + var v: UInt64 = 0 + Scanner(string: s).scanHexInt64(&v) + let r, g, b: CGFloat + switch s.count { + case 6: + r = CGFloat((v >> 16) & 0xFF) / 255 + g = CGFloat((v >> 8) & 0xFF) / 255 + b = CGFloat(v & 0xFF) / 255 + default: + r = 0; g = 0; b = 0 + } + self.init(red: r, green: g, blue: b, alpha: 1) + } +} + // MARK: - Palette enum Palette { - static let orange = Color("KisaniOrange") // #FF5C00 - static let black = Color.black // #000000 - static let surface = Color(hex: "111111") - static let surface2 = Color(hex: "1A1A1A") - static let hairline = Color(hex: "1A1A1A") + static let orange = Color("KisaniOrange") // #FF5C00 + + static func adaptive(dark: String, light: String) -> Color { + Color(UIColor { traits in + traits.userInterfaceStyle == .dark ? UIColor(hex: dark) : UIColor(hex: light) + }) + } + + static let background = adaptive(dark: "0A0A0A", light: "F7F4EF") + static let black = background + static let surface = adaptive(dark: "111111", light: "EFE9DD") + static let surface2 = adaptive(dark: "1A1A1A", light: "E3DBCE") + static let hairline = adaptive(dark: "1A1A1A", light: "D6CEC0") + + static let primaryText = adaptive(dark: "F5F2ED", light: "171411") + static let secondaryText = adaptive(dark: "9A9A9A", light: "4F4941") + static let tertiaryText = adaptive(dark: "666666", light: "6F675E") + static let mutedText = adaptive(dark: "4A4A4A", light: "948B80") + static let chipFill = adaptive(dark: "242424", light: "DDD3C1") + static let chipText = adaptive(dark: "E0E0E0", light: "28231E") + static let chipMutedText = adaptive(dark: "787878", light: "68625A") // health - static let healthActive = Color(hex: "2A5A2A") - static let healthFailing = Color(hex: "AA6600") - static let healthDead = Color(hex: "5A1A1A") + static let healthActive = adaptive(dark: "2A5A2A", light: "DCEBDD") + static let healthFailing = adaptive(dark: "AA6600", light: "F2D6A6") + static let healthDead = adaptive(dark: "5A1A1A", light: "E9C7C7") // blocks static let consensusBorder = Color(hex: "FF5C00") - static let consensusFill = Color(hex: "1F1206") // dark orange - static let conflictBorder = Color(hex: "5A1A1A") // dark red - static let conflictFill = Color(hex: "1A0C0C") // dark red + static let consensusFill = adaptive(dark: "1F1206", light: "FFE2CA") + static let conflictBorder = adaptive(dark: "5A1A1A", light: "BF5B5B") + static let conflictFill = adaptive(dark: "1A0C0C", light: "F3D8D8") - static let cachedGreen = Color(hex: "2A5A2A") - static let bodyText = Color(hex: "4A4A4A") + static let cachedGreen = adaptive(dark: "2A5A2A", light: "2C6A38") + static let bodyText = adaptive(dark: "8A8A8A", light: "4A453F") } // MARK: - Signal-score fade @@ -67,6 +103,30 @@ enum Signal { opacity: 1) } + private static func lerpTuple(_ a: (Double, Double, Double), + _ b: (Double, Double, Double), + _ t: Double) -> (Double, Double, Double) { + let t = max(0, min(1, t)) + return ( + a.0 + (b.0 - a.0) * t, + a.1 + (b.1 - a.1) * t, + a.2 + (b.2 - a.2) * t + ) + } + + private static func adaptiveLerp(_ darkA: (Double, Double, Double), + _ darkB: (Double, Double, Double), + _ lightA: (Double, Double, Double), + _ lightB: (Double, Double, Double), + _ t: Double) -> Color { + Color(UIColor { traits in + let rgb = traits.userInterfaceStyle == .dark + ? lerpTuple(darkA, darkB, t) + : lerpTuple(lightA, lightB, t) + return UIColor(red: rgb.0, green: rgb.1, blue: rgb.2, alpha: 1) + }) + } + /// 3pt left stripe color: #1A1A1A (0) → #FF5C00 (97+). static func stripeColor(_ score: Int) -> Color { let t = Double(score) / 97.0 @@ -77,18 +137,21 @@ enum Signal { /// Story title color: white (97+) → #444444 (20) → near invisible (0). static func titleColor(_ score: Int) -> Color { - if score >= 97 { return .white } + if score >= 97 { return Palette.primaryText } if score >= 20 { let t = Double(score - 20) / Double(97 - 20) - return lerp((0x44/255, 0x44/255, 0x44/255), // #444444 - (1, 1, 1), // white - t) + return adaptiveLerp( + (0x44/255, 0x44/255, 0x44/255), (0xF5/255, 0xF2/255, 0xED/255), + (0x8A/255, 0x81/255, 0x76/255), (0x17/255, 0x14/255, 0x11/255), + t + ) } - // 0–19 near invisible: #1C1C1C → #444444 let t = Double(score) / 20.0 - return lerp((0x1C/255, 0x1C/255, 0x1C/255), - (0x44/255, 0x44/255, 0x44/255), - t) + return adaptiveLerp( + (0x1C/255, 0x1C/255, 0x1C/255), (0x44/255, 0x44/255, 0x44/255), + (0xD6/255, 0xCE/255, 0xC0/255), (0x8A/255, 0x81/255, 0x76/255), + t + ) } /// Signal score number color: stays in orange/brown hue, fades with score. @@ -195,14 +258,31 @@ enum AppearanceMode: String, CaseIterable { } } -// MARK: - Stable feed ordering +// MARK: - Freshness-weighted feed ordering +// +// The server already applies freshness when computing signalScore, but that +// snapshot ages once it's cached on the client. clientRank applies a lightweight +// time-decay so fresh stories naturally surface over stale high-scorers. +// +// Formula: rank = score × (0.80 + 0.20 × decay) +// decay = 1 / (1 + ageHours / 24) → 1.0 now · 0.50 @ 24 h · 0.33 @ 48 h +// +// Effect: a score-80 story from 1 hour ago (rank ≈ 79.8) beats a score-82 story +// from 48 hours ago (rank ≈ 71.4). Pure score still dominates for same-age stories. extension StorySummary { - /// Deterministic ranking: signal score descending, then id descending — the - /// same order the backend uses (`signal_score DESC, id DESC`). Without the - /// id tiebreaker, equal-score stories reshuffle on every (unstable) re-sort. + var clientRank: Double { + let ageHours = max(0.0, -updatedAt.timeIntervalSinceNow) / 3600.0 + let decay = 1.0 / (1.0 + ageHours / 24.0) + return Double(signalScore) * (0.80 + 0.20 * decay) + } + static func feedOrder(_ a: StorySummary, _ b: StorySummary) -> Bool { - a.signalScore != b.signalScore ? a.signalScore > b.signalScore : a.id > b.id + let diff = a.clientRank - b.clientRank + // Stable tiebreak for nearly-equal ranks: newer ID wins (IDs are + // lexicographically monotone from the backend). + if abs(diff) < 0.5 { return a.id > b.id } + return diff > 0 } } @@ -216,9 +296,9 @@ enum StoryPill: String, CaseIterable, Identifiable { case all = "All" case f1 = "F1" case sport = "Sport" - case tech = "Tech" // covers AI, Cloud, HomeLab — use sub-sections inside - case uganda = "Uganda" - case southAfrica = "South Africa" + case tech = "Tech" // AI, Cloud, HomeLab — sub-sections inside + case eastAfrica = "East Africa" // Uganda + broader East Africa, Uganda pinned first + case southernAfrica = "Southern Africa" // SA, Namibia, Botswana pinned first case canada = "Canada" case unitedStates = "US" @@ -227,28 +307,33 @@ enum StoryPill: String, CaseIterable, Identifiable { var slugs: Set { switch self { - case .all: return [] - case .f1: return ["formula-1"] - case .sport: return ["sports", "esports"] - case .tech: return [ + case .all: return [] + case .f1: return ["formula-1"] + case .sport: return ["sports", "esports"] + case .tech: return [ "technology", "programming-and-software-development", "cybersecurity", "security", "privacy-and-data-protection", "web-design-and-ui-ux", "wordpress-and-web-development", "artificial-intelligence", "machine-learning", "robotics", "cloud-computing", "homelab", ] - case .uganda: return ["uganda"] - case .southAfrica: return ["south-africa"] - case .canada: return ["canada"] - case .unitedStates: return ["united-states"] + case .eastAfrica: return ["east-africa", "uganda"] + case .southernAfrica: return [ + "south-africa", "namibia", "botswana", + "zimbabwe", "zambia", "mozambique", "southern-africa", + ] + case .canada: return ["canada"] + case .unitedStates: return ["united-states"] } } /// Sub-sections to show inside the card layout for this pill (nil = flat). var subSections: [NewsSection]? { switch self { - case .tech: return NewsSection.techSubSections - default: return nil + case .tech: return NewsSection.techSubSections + case .eastAfrica: return NewsSection.eastAfricaSubSections + case .southernAfrica: return NewsSection.southernAfricaSubSections + default: return nil } } } @@ -278,7 +363,7 @@ struct JarvisWordmark: View { var body: some View { HStack(spacing: 0) { Text("j").font(font).foregroundStyle(Palette.orange) - Text("arvis").font(font).foregroundStyle(.white) + Text("arvis").font(font).foregroundStyle(Palette.primaryText) } } } @@ -300,7 +385,9 @@ struct NewsSection: Identifiable { .init(id: "politics", label: "Politics", slugs: ["politics", "human-rights", "investigative"], pill: nil), .init(id: "business", label: "Business", slugs: ["business", "finance", "fintech", "cryptocurrency", "blockchain", "entrepreneur", "startups", "real-estate"], pill: nil), .init(id: "tech", label: "Tech", slugs: ["technology", "programming-and-software-development", "cybersecurity", "security", "web-design-and-ui-ux", "wordpress-and-web-development", "privacy-and-data-protection", "cloud-computing", "artificial-intelligence", "machine-learning", "robotics", "homelab"], pill: .tech), - .init(id: "africa", label: "Africa", slugs: ["africa", "east-africa", "south-africa", "uganda"], pill: nil), + .init(id: "east-africa", label: "East Africa", slugs: ["east-africa", "uganda"], pill: .eastAfrica), + .init(id: "southern-africa",label: "Southern Africa", slugs: ["south-africa", "namibia", "botswana", "zimbabwe", "zambia", "mozambique", "southern-africa"], pill: .southernAfrica), + .init(id: "africa", label: "Africa", slugs: ["africa"], pill: nil), .init(id: "sport", label: "Sport", slugs: ["sports", "esports", "formula-1"], pill: .sport), .init(id: "science", label: "Science", slugs: ["science", "astronomy", "environment", "green-technology", "sustainability"], pill: nil), .init(id: "entertainment", label: "Entertainment",slugs: ["entertainment", "music", "pop-culture", "video-game", "arts-and-culture", "books-and-literature"], pill: nil), @@ -317,4 +404,20 @@ struct NewsSection: Identifiable { .init(id: "t-dev", label: "Dev", slugs: ["programming-and-software-development", "web-design-and-ui-ux", "wordpress-and-web-development"], pill: nil), .init(id: "t-tech", label: "Technology", slugs: ["technology"], pill: nil), ] + + /// Sub-sections shown inside the East Africa pill view — Uganda pinned first. + static let eastAfricaSubSections: [NewsSection] = [ + .init(id: "ea-uganda", label: "Uganda", slugs: ["uganda"], pill: nil), + .init(id: "ea-kenya", label: "Kenya", slugs: ["east-africa"], pill: nil), + ] + + /// Sub-sections shown inside the Southern Africa pill — SA, Namibia, Botswana first. + static let southernAfricaSubSections: [NewsSection] = [ + .init(id: "sa-za", label: "South Africa", slugs: ["south-africa"], pill: nil), + .init(id: "sa-na", label: "Namibia", slugs: ["namibia"], pill: nil), + .init(id: "sa-bw", label: "Botswana", slugs: ["botswana"], pill: nil), + .init(id: "sa-zw", label: "Zimbabwe", slugs: ["zimbabwe"], pill: nil), + .init(id: "sa-zm", label: "Zambia", slugs: ["zambia"], pill: nil), + .init(id: "sa-mz", label: "Mozambique", slugs: ["mozambique"], pill: nil), + ] } diff --git a/Jarvis/Views/Story/StoryDetailView.swift b/Jarvis/Views/Story/StoryDetailView.swift index d0f0b2d..6cac6b4 100644 --- a/Jarvis/Views/Story/StoryDetailView.swift +++ b/Jarvis/Views/Story/StoryDetailView.swift @@ -69,20 +69,21 @@ struct StoryDetailView: View { var body: some View { ZStack { - Color.black.ignoresSafeArea() + Palette.background.ignoresSafeArea() ScrollView { VStack(alignment: .leading, spacing: 20) { categoryRow Text(headline) - .font(.system(size: 28, weight: .heavy)) - .foregroundStyle(.white) + .font(.system(size: 31, weight: .bold, design: .default)) + .lineSpacing(2) + .foregroundStyle(Palette.primaryText) .fixedSize(horizontal: false, vertical: true) if !summary.isEmpty { Text(summary) - .font(.system(size: 15, weight: .regular)) - .foregroundStyle(Color(hex: "9A9A9A")) - .lineSpacing(4) + .font(.system(size: 18, weight: .regular, design: .default)) + .foregroundStyle(Palette.secondaryText) + .lineSpacing(5) } SourceChips(names: sourceNames, total: sourceCount) @@ -123,7 +124,7 @@ struct StoryDetailView: View { } } } - .toolbarBackground(Color.black, for: .navigationBar) + .toolbarBackground(Palette.background, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) .sheet(isPresented: $showShare) { ShareSheet(items: shareContent) } .task { @@ -195,7 +196,7 @@ struct StoryDetailView: View { .font(.system(size: 11, weight: .bold, design: .monospaced)) .kerning(0.8) .foregroundStyle(Color(hex: "888888")) - Text("·").foregroundStyle(Color(hex: "444444")) + Text("·").foregroundStyle(Palette.mutedText) Text("\(score) signal") .font(.system(size: 11, weight: .bold, design: .monospaced)) .foregroundStyle(Signal.scoreColor(score)) @@ -228,8 +229,8 @@ struct StoryDetailView: View { .kerning(0.8) .foregroundStyle(titleColor) Text(text) - .font(.system(size: 14, weight: .regular)) - .foregroundStyle(Color(hex: "C8C8C8")) + .font(.system(size: 16, weight: .regular, design: .default)) + .foregroundStyle(Palette.secondaryText) .lineSpacing(3) .fixedSize(horizontal: false, vertical: true) } @@ -245,7 +246,7 @@ struct StoryDetailView: View { Text("COVERAGE TIMELINE") .font(.system(size: 10, weight: .bold, design: .monospaced)) .kerning(0.8) - .foregroundStyle(Color(hex: "555555")) + .foregroundStyle(Palette.tertiaryText) .padding(.bottom, 14) if vm.isLoading && timeline.isEmpty { @@ -253,7 +254,7 @@ struct StoryDetailView: View { } else if timeline.isEmpty { Text(vm.error ?? "No coverage available.") .font(.system(size: 13)) - .foregroundStyle(Color(hex: "555555")) + .foregroundStyle(Palette.tertiaryText) } else { ForEach(Array(timeline.enumerated()), id: \.element.id) { index, entry in NavigationLink(value: ArticleRoute(articleId: entry.articleId, @@ -275,11 +276,11 @@ struct StoryDetailView: View { // Node + connector VStack(spacing: 0) { Circle() - .fill(isFirst ? Palette.orange : Color(hex: "333333")) + .fill(isFirst ? Palette.orange : Palette.mutedText) .frame(width: 11, height: 11) - .overlay(Circle().stroke(Color.black, lineWidth: 2)) + .overlay(Circle().stroke(Palette.background, lineWidth: 2)) if !isLast { - Rectangle().fill(Color(hex: "222222")).frame(width: 1.5) + Rectangle().fill(Palette.hairline).frame(width: 1.5) } } .frame(width: 11) @@ -287,12 +288,12 @@ struct StoryDetailView: View { VStack(alignment: .leading, spacing: 5) { HStack(spacing: 8) { Text(entry.source) - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(isFirst ? Palette.orange : Color(hex: "999999")) + .font(.system(size: 14, weight: .semibold, design: .default)) + .foregroundStyle(isFirst ? Palette.orange : Palette.primaryText) if entry.isBreaking { Text("BREAKING") .font(.system(size: 9, weight: .bold, design: .monospaced)) - .foregroundStyle(.white) + .foregroundStyle(Palette.primaryText) .padding(.horizontal, 5).padding(.vertical, 2) .background(Palette.conflictBorder) .clipShape(Capsule()) @@ -300,12 +301,12 @@ struct StoryDetailView: View { Spacer() Text(entry.publishedAt.clockShort) .font(.system(size: 11, weight: .regular, design: .monospaced)) - .foregroundStyle(Color(hex: "555555")) + .foregroundStyle(Palette.tertiaryText) if cached { CachedDot(size: 6) } } Text(entry.headline) - .font(.system(size: 15, weight: .regular)) - .foregroundStyle(Color(hex: "D0D0D0")) + .font(.system(size: 16, weight: .regular, design: .default)) + .foregroundStyle(Palette.secondaryText) .fixedSize(horizontal: false, vertical: true) .multilineTextAlignment(.leading) } diff --git a/project.yml b/project.yml index e6d162f..6ca6b64 100644 --- a/project.yml +++ b/project.yml @@ -20,7 +20,6 @@ targets: UIColorName: "" UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait - UIUserInterfaceStyle: Dark NSLocalNetworkUsageDescription: "Jarvis connects to your self-hosted news platform on the local network." NSAppTransportSecurity: NSAllowsArbitraryLoads: true