feat: East Africa + Southern Africa pills with regional sub-sections
Some checks failed
CI / Build · macos-14 · Debug (push) Has been cancelled
CI / Build · macos-15 · Debug (push) Has been cancelled
CI / Build · macos-14 · Release (push) Has been cancelled
CI / Build · macos-15 · Release (push) Has been cancelled

- East Africa pill: merges Uganda + east-africa tags; Uganda pinned first via sub-sections
- Southern Africa pill: covers SA, Namibia, Botswana, Zimbabwe, Zambia, Mozambique; SA/Namibia/Botswana prioritized
- StoryStore.setPill now resets lastSyncedAt to bypass 30s rate-limit on filter switch
- sectionSupplement background fetch populates sparse regional sections in All digest
- SignalFeedView digestContent: flat hero+scroll path for pills without subSections (was showing max 5)
- Theme: removed standalone Uganda pill, wired new sub-section layouts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-06-22 02:33:33 +03:00
parent 6677033faa
commit 595f21fb6c
27 changed files with 635 additions and 293 deletions

View File

@@ -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 25
// that may have arrived since last session.
await StoryStore.shared.loadFeedFull()
}
}

View File

@@ -5,6 +5,7 @@
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.kisani.jarvis.briefing.refresh</string>
<string>com.kisani.jarvis.feed.refresh</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
@@ -53,7 +54,5 @@
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
</dict>
</plist>

View File

@@ -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()

View File

@@ -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()
}
}

View File

@@ -68,12 +68,14 @@ actor APIClient {
func fetchStories(
cursor: String? = nil,
topic: String? = nil,
tags: Set<String> = [],
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)
}

View File

@@ -5,7 +5,7 @@ import Foundation
import Combine
enum ConnectionState {
case disconnected
case disconnected
case connecting
case connected
case reconnecting(attempt: Int)

View File

@@ -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()
}

View File

@@ -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 = ""

View File

@@ -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.

View File

@@ -17,23 +17,90 @@ final class StoryStore: ObservableObject {
@Published var hasMore = false
@Published var selectedTopic: String? = nil
@Published var selectedTags: Set<String> = []
@Published var sectionSupplement: [String: [StorySummary]] = [:]
private var nextCursor: String?
private var refreshTask: Task<Void, Never>?
private var foregroundSyncTask: Task<Void, Never>?
private var cancellables = Set<AnyCancellable>()
// 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<CachedStory>()) {
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<String>)] = [
("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

View File

@@ -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<Content: View>(@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 {

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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<C: View>(_ 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<Bool>) -> 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)
}

View File

@@ -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<C: View>(_ 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)

View File

@@ -39,5 +39,5 @@ struct CachedBadge: View {
CachedBadge(text: "All 7 articles cached · available offline")
}
.padding()
.background(Color.black)
.background(Palette.background)
}

View File

@@ -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)
}

View File

@@ -29,5 +29,5 @@ struct SignalStripe: View {
}
}
.padding()
.background(Color.black)
.background(Palette.background)
}

View File

@@ -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)
}

View File

@@ -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
)
}
// 019 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<String> {
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),
]
}

View File

@@ -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)
}

View File

@@ -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