feat: East Africa + Southern Africa pills with regional sub-sections
- 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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import Foundation
|
||||
import Combine
|
||||
|
||||
enum ConnectionState {
|
||||
case disconnected
|
||||
case disconnected
|
||||
case connecting
|
||||
case connected
|
||||
case reconnecting(attempt: Int)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -39,5 +39,5 @@ struct CachedBadge: View {
|
||||
CachedBadge(text: "All 7 articles cached · available offline")
|
||||
}
|
||||
.padding()
|
||||
.background(Color.black)
|
||||
.background(Palette.background)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -29,5 +29,5 @@ struct SignalStripe: View {
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.black)
|
||||
.background(Palette.background)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<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),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user