Compare commits

...

42 Commits

Author SHA1 Message Date
Kutesir
bd632a1592 rename: Jarvis -> Jervis target, scheme, and folder throughout
Some checks failed
CI / Build · Debug (push) Has been cancelled
CI / Build · Release (push) Has been cancelled
The bundle-ID and display-name renames weren't enough — the actual
Xcode target/product name was still "Jarvis", which drives CFBundleName,
Xcode's Organizer archive list, the .xcodeproj filename, and the scheme
name. Renamed all the way through:

- project.yml: top-level name, target key, PRODUCT_NAME, source/info paths
- Jarvis/ -> Jervis/ (source folder, git-tracked as renames, no content
  diffs on the moved files)
- .gitignore, CI workflows, README, CONTRIBUTING: Jarvis.xcodeproj /
  scheme Jarvis -> Jervis.xcodeproj / scheme Jervis

Verified: xcodebuild -scheme Jervis succeeds, produces Jervis.app,
CFBundleName/CFBundleExecutable/CFBundleDisplayName all read "Jervis".
CFBundleIdentifier intentionally stays com.kisani.jarvis (per prior
decision — bundle ID isn't user-facing anywhere including Organizer).

Swift type names (JarvisApp, JarvisWordmark, etc.) and the Gitea repo
name/URL are unchanged — pure internal source identifiers, not user or
developer-facing product identity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 03:49:31 +03:00
Kutesir
3a6f1bbdd8 fix: build number 1 -> 2, and stop xcodegen silently resetting it
Some checks failed
CI / Build · Debug (push) Has been cancelled
CI / Build · Release (push) Has been cancelled
CFBundleVersion/CFBundleShortVersionString weren't declared in
project.yml's properties block, so xcodegen defaulted them to "1"/"1.0"
on every regenerate regardless of CURRENT_PROJECT_VERSION/
MARKETING_VERSION — bumping the build setting silently did nothing.
Declared both explicitly (matching how CFBundleIdentifier and
CFBundleExecutable already reference build settings), so version bumps
now actually reach the compiled app. Verified: CFBundleVersion "2" in
the built binary's Info.plist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 03:42:14 +03:00
Kutesir
bb067df482 docs: record the stale-news / clientRank fix as a key decision
Some checks failed
CI / Build · Debug (push) Has been cancelled
CI / Build · Release (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 03:17:22 +03:00
Kutesir
326d5fe983 fix: stale-news control — repair clientRank's age source, add isFossil backstop
Some checks failed
CI / Build · Debug (push) Has been cancelled
CI / Build · Release (push) Has been cancelled
clientRank already existed specifically to stop stale high-scorers from
leading the feed, but computed age from updatedAt — the same timestamp
that keeps resetting whenever the backend attaches any article to a
cluster (BACKLOG #15). A fossil's age always read as ~0, silently
defeating the whole mechanism. Fixed to use firstSeenAt.

clientRank's decay is soft (max 20% penalty) and isn't always enough
against a high signalScore alone, so added StorySummary.isFossil (>5
days old by firstSeenAt) as a hard backstop: mainStories and
makeDigest now sink fossils below current stories, so one can never
take the hero/lead slot regardless of score. Still shown in the feed,
just not in the most prominent position.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 02:56:34 +03:00
Kutesir
676119b328 revert: keep bundle ID as com.kisani.jarvis
Some checks failed
CI / Build · Debug (push) Has been cancelled
CI / Build · Release (push) Has been cancelled
The bundle ID never appears in the App Store or on-device UI — only
CFBundleDisplayName (home screen / notifications) and the App Store
Connect "Name" field do, both already "Jervis". Changing the bundle ID
was unnecessary scope creep; there's no existing App Store Connect
record tied to the old identifier to worry about, and a mismatched
bundle ID vs. public name is completely normal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 02:49:04 +03:00
Kutesir
b44e708fbb rename: JarvisIcon -> JervisIcon asset (splash screen)
Some checks failed
CI / Build · Debug (push) Has been cancelled
CI / Build · Release (push) Has been cancelled
Same radar-rings artwork, just the asset name catching up to the
Jervis rebrand. No visual change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 02:32:35 +03:00
Kutesir
d93ffb1580 rebrand: Jarvis -> Jervis for App Store submission
Some checks failed
CI / Build · Debug (push) Has been cancelled
CI / Build · Release (push) Has been cancelled
"JARVIS" is Marvel's trademark for the Iron Man/Avengers AI — real
legal risk for a commercial App Store listing, and the name is already
contested there ("Jarvis: Powered by Marvel", "Jarvis - AI Chatbot").
Jervis reads identically but doesn't collide.

Scoped to user-facing surfaces only:
- CFBundleDisplayName, PRODUCT_BUNDLE_IDENTIFIER (com.kisani.jervis)
- BGTaskSchedulerPermittedIdentifiers + matching Swift constants
- NSLocalNetworkUsageDescription permission prompt copy
- JarvisWordmark rendered text ("j" + "ervis")
- Notification permission prompt copy

Internal Xcode target/executable name, Swift type names (JarvisWordmark,
JarvisApp), file/folder names, and repo name are unchanged — renaming
those is a separate, larger refactor with no user-facing benefit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 02:12:48 +03:00
Kutesir
afec870919 fix: display story age from firstSeenAt, not updatedAt
All checks were successful
CI / Build · Debug (push) Successful in 22s
CI / Build · Release (push) Successful in 22s
Old stories were reading "1 hr ago" because updatedAt bumps every time
the backend attaches another article to a cluster, however loosely
related. firstSeenAt is when the cluster actually first appeared and
is already in the API response — just wasn't decoded.

Verified live: story_1a37c858235b (F1 Austria GP recap) has
firstSeenAt 2026-06-16 but updatedAt 2026-07-11, freshnessScore 0.96 —
looked brand new despite being 25 days old. Also confirmed the API's
isStale field is unusable as a stopgap (always false regardless of age).

Ranking still surfaces old stories at the top — that's server-side
(signalScore/freshnessScore) and out of scope for the client per the
architecture rule against re-ranking. Logged in BACKLOG #15.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 01:51:35 +03:00
Kutesir
fcd4e2cce5 docs: log freshness-decay bug — old stories never leave the top of the feed
All checks were successful
CI / Build · Debug (push) Successful in 24s
CI / Build · Release (push) Successful in 30s
Live-verified against the backend API: 13/20 top stories are 5+ days old
by firstSeenAt but still score 90+. Freshness decays from lastUpdatedAt
(cluster last touched) instead of the actual news event age, and it
compounds with the known over-clustering bug (#2) — tangential articles
keep attaching to old clusters, resetting their clock indefinitely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 03:17:25 +03:00
Kutesir
0b10009d97 ci: re-trigger — runner Xcode moved to /Applications/Xcode.app on the volume
All checks were successful
CI / Build · Debug (push) Successful in 43s
CI / Build · Release (push) Successful in 17s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 00:31:56 +03:00
Kutesir
3c8d9b5736 ci: re-trigger now that the runner is loaded via LaunchAgent
Some checks failed
CI / Build · Debug (push) Failing after 7s
CI / Build · Release (push) Failing after 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 00:08:23 +03:00
Kutesir
1476906756 ci: build against device SDK — CoreSimulator hangs on external-volume Xcode
Some checks failed
CI / Build · Debug (push) Failing after 33m6s
CI / Build · Release (push) Failing after 41m24s
Simulator-destination builds flap on CoreSimulatorService for an hour then
die. Device SDK with signing disabled gives identical compile coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 19:18:29 +03:00
Kutesir
d62c3511f6 fix: use modelContext.container — \.modelContainer is not an Environment key
Some checks failed
CI / Build · Debug (push) Waiting to run
CI / Build · Release (push) Has been cancelled
CI caught this: the sync-lag fix referenced a nonexistent SwiftUI
environment value. The container is reached through the context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 16:09:32 +03:00
Kutesir
5419540648 docs: correct act_runner setup — binary install, host labels, docker-less config
Some checks failed
CI / Build · Debug (push) Failing after 29s
CI / Build · Release (push) Failing after 7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 02:16:02 +03:00
Kutesir
f7164e2978 ci: trigger run for runner debugging
Some checks failed
CI / Build · Debug (push) Failing after 3s
CI / Build · Release (push) Failing after 2s
2026-07-03 01:38:21 +03:00
Kutesir
457866c7ac perf: move SwiftData cache and JSON encode off main thread during sync
Some checks failed
CI / Build · Debug (push) Failing after 10s
CI / Build · Release (push) Failing after 2s
- cacheStories() runs in Task.detached with a background ModelContext
- persistQuickCache() encodes 100 stories off-thread via Task.detached
- loadSectionSupplements() batches 5 parallel fetches into one sectionSupplement write

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 13:14:27 +03:00
Robin Kutesa
217a51b013 fix(notifications): remove emoji from briefing title and body
"Good morning ☀️" / "Good evening 🌙" → "Good morning" / "Good evening"
"📣 Needs your attention:" → "Needs your attention:"

Co-Authored-By: Kutesir <tqwyy79vzn@privaterelay.appleid.com>
2026-06-23 19:13:16 +03:00
Robin Kutesa
a86284cb8a feat: Mark All as Read — bulk action with optimistic UI and rollback
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
APIClient:
- markAllRead(storyIds💊) — POST /me/read/bulk with device ID, timestamp,
  source label, and pill name so backend can record context

Models:
- PaginatedStories gains unreadCount, readSuppressedCount, lastReadSyncAt,
  activePill — backend now returns these so the UI can report honest state

SignalFeedView:
- Header "…" menu replaces the standalone settings button; menu contains
  "Mark all as read" (disabled while pending or when nothing is unread) and
  a Settings item
- markAllRead() captures all visible unread IDs, optimistically inserts
  ReadStory for each, shows toast ("Marked N stories as read"), then fires
  the bulk API; on failure deletes the optimistic ReadStory records and
  shows an honest error toast ("Sync failed. Stories may reappear until retry.")
- Toast auto-dismisses after 3.5s, appears at the bottom of the ZStack
  with a slide-up + opacity animation

Cross-pill suppression was already in place — backend applies
unread_story_condition() to every /stories request regardless of tags,
so stories marked read in All disappear from Tech, Sport, etc. on the
next pill fetch.

Co-Authored-By: Kutesir <tqwyy79vzn@privaterelay.appleid.com>
2026-06-23 12:47:28 +03:00
Robin Kutesa
044f67cf63 feat: sync read/unread state to backend with persistent device ID
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
- APIClient: generate and persist a stable device UUID (jarvis.device.id
  in UserDefaults); attach as X-Jarvis-Device-Id header on every request
- Add markStoryRead / markStoryUnread — POST/DELETE /stories/:id/read with
  device_id, read_at, and source fields
- Wire both calls into all three read-toggle sites: SignalFeedView,
  ArticleReaderView, StoryDetailView
- Add postVoid helper for fire-and-forget POST calls (no response body needed)
- Pass include_read param on stories fetch for future server-side filtering
- CacheMaintenance: recentHours 84→72 (mirrors backend 72h story window);
  markerHours 96→192 so read suppression survives a full weekly news cycle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 19:35:45 +03:00
Robin Kutesa
a476e426f3 docs: rewrite CLAUDE.md with architecture rules and current project state
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
Establishes the core contract: RSS → backend cache → API → frontend.
Frontend must never fetch RSS. Updates project structure (all views now
built), pill→tag mapping, key decisions, and adds reporting requirement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 02:36:19 +03:00
Robin Kutesa
595f21fb6c 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>
2026-06-22 02:33:33 +03:00
Robin Kutesa
6677033faa feat: offline article caching + smart pull-to-refresh reshuffle
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
Offline caching:
- BackgroundRefreshManager.preCacheArticles() fetches top article per story
  (score ≥60, up to 20 stories) and stores as CachedArticle in SwiftData
- Runs after every BG fetch AND after every foreground sync via StoryStore
- Articles are available in ArticleReaderView offline without ever opening them

Pull-to-refresh reshuffle when nothing new:
- loadStories(refresh:) now returns newCount (genuinely new story IDs)
- If server returns 0 new stories, feed merges in any BG-cached stories not
  already in the live feed, then re-sorts by signal score (reshuffle)
- seenSnapshot is always cleared so everything re-ranks fresh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 03:01:44 +03:00
Robin Kutesa
a1d7979048 feat: automatic foreground sync — 5 min timer + resume refresh
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
StoryStore gains startForegroundSync() / stopForegroundSync():
- Polls loadStories(refresh:true) every 5 minutes while app is active
- WebSocket handles real-time storyCreated events between polls
- Timer cancels when app goes to background to save battery

JarvisApp wires scenePhase:
- .active  → start 5-min timer; immediate refresh if last sync > 2 min ago
- .background → cancel timer
- On launch → timer starts after setup completes

Net result: feed replenishes automatically; user never hits an empty list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 02:57:54 +03:00
Robin Kutesa
7a97118d12 feat: merge AI / Cloud / HomeLab into Tech pill with 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
- Removed AI, Cloud, HomeLab as top-level pills
- Tech pill now matches all their slugs (ai, ml, cloud, homelab, security, dev)
- Tech view shows a sectioned front page: AI · Cloud · HomeLab · Security · Dev · Technology
- Sub-sections defined via NewsSection.techSubSections + StoryPill.subSections
- digest() extracted to makeDigest(sections:) so any pill can have sections
- Tech auto-loads 100 stories to fill its sub-sections (same as All)
- "All" Tech section in the main feed now links to Tech pill

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 02:53:41 +03:00
Robin Kutesa
ff304d1fda fix: splash screen now loads via JarvisIcon imageset (UIImage named: AppIcon doesn't resolve at runtime)
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
Added JarvisIcon.imageset containing the radar PNG so Image("JarvisIcon")
loads reliably. Also switched shadow tint to orange to echo the icon glow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 02:41:29 +03:00
Robin Kutesa
694880eb4c polish: vivid orange radar icon — brighter ring peaks + dual glow bloom
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
- Gamma lift 0.70 pushes ring peaks toward full #FF5C00 saturation
- Faster ring-factor curve (^0.32) keeps more of each band at full brightness
- Tight 9px glow + wide 26px halo screen-blended for the orange fire feel
- Gap darkness stays near-black (^2 × 0.025) for maximum ring contrast

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 02:33:14 +03:00
Robin Kutesa
03a1e45531 revert: restore radar concentric-rings app icon
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
Replaces the 3-bar horizontal design with the original radar/signal
icon — concentric orange rings fading to near-black at center and edge,
15 ring bands, warm dark background. Matches the "Signal Intelligence" J icon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 02:29:48 +03:00
Robin Kutesa
b53a4b8f5d fix: splash screen uses actual app icon in a circle (Wenza style)
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
Replaces the custom 3-bar SwiftUI drawing with UIImage(named:"AppIcon")
clipped to a circle, matching the Wenza splash pattern exactly:
- Icon bounces in with spring (scale 0.82→1, opacity 0→1)
- "jarvis" wordmark fades in 320ms later
- Whole screen fades out at 1.6s, onDismiss fires at 1.9s
- Main app renders underneath so it's warm when the splash clears

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 02:24:07 +03:00
Robin Kutesa
207b1827c7 feat: hero card layout in all pills, cache-while-loading, logo splash screen
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
- Every pill (F1, Tech, AI, Cloud, HomeLab…) now shows the same hero card
  layout as "All" — first story is the big featured card, rest follow as rows
- "All" retains its full multi-section front page (US / Tech / AI / Sport…)
- Topic pill headers use the pill name instead of "TOP STORIES"
- filteredStories now falls back to cached SwiftData rows while loading, so
  switching to F1 or Tech shows cached cards instantly instead of a blank list
- Logo splash screen (3 pill bars + jarvis wordmark) shows on launch and fades
  out after setup completes; minimum 1.3 s so it's always readable
- JarvisWordmark moved to Theme.swift so SplashView and feed can share it

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 02:15:23 +03:00
Robin Kutesa
760e6a121c fix: resolve Swift 6 concurrency warnings in WebSocketManager
- listen(): capture `task` as local `t` before the Sendable receive closure
  so the @MainActor-isolated property isn't referenced inside a Sendable context
- startPing(): dispatch to @MainActor inside the timer block before accessing `task`
- scheduleReconnect(): capture `self` weakly in the reconnect Task

XcodeGen regenerated to include BackgroundRefreshManager.swift in the project.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 02:06:01 +03:00
Robin Kutesa
eec610024c feat: read-shelf auto-expand, stripe removal, pull-reshuffle, BG notifications
- Mark-read now immediately expands the READ shelf so the story is visible
- Orange signal stripe is fully removed (not dimmed) when isRead=true
- Pull-to-refresh clears seen snapshot → full reshuffle by signal score
- BackgroundRefreshManager fires a local notification for the top new
  high-signal story after each cache refresh; Tech/AI/HomeLab stories
  get their category in the notification title (≥65 signal threshold)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 01:58:51 +03:00
Robin Kutesa
3b09fa2889 feat(bg): add background feed refresh every ~30 min
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
Registers com.kisani.jarvis.feed.refresh as a BGAppRefreshTask.
On each fire it fetches the latest 40 stories and upserts CachedStory
rows so the SwiftData cache is warm before the user opens the app.
Already-cached rows have signalScore / sourceCount refreshed in-place.

Single ModelContainer owned by JarvisApp and shared via
BackgroundRefreshManager.container to avoid multi-container conflicts.
BG task ID registered in AppDelegate and declared in project.yml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 01:42:20 +03:00
Robin Kutesa
7085a4dd45 feat(reader): redesign ArticleReaderView + global appearance toggle
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
Reading experience:
- Body text #CECECE (dark) / #1A1916 (light) — was near-invisible #4A4A4A
- 30pt heavy headline, 17pt body at 8.5pt line-spacing
- Skeleton pulse loading state replaces dark spinner box
- Hero image fades in on load (0.3s ease-in)
- Orange reading progress bar (2pt) below nav bar
- Max reading width 680pt for iPad legibility

Appearance:
- AppearanceMode enum (system/light/dark) in Theme.swift
- @AppStorage("appearanceMode") applied once in JarvisApp root
- Appearance picker (Picker in Menu) in reader toolbar
- Removed .preferredColorScheme(.dark) from all 12 child views

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 01:30:36 +03:00
Robin Kutesa
fb2e0ff9a2 fix(icon): pixel-perfect vertical centering (319px each side)
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
Orange glow bleeds 15px upward beyond the bar boundary.
Y_START=(1024-370+15)/2=334 compensates so the visible
content sits with exactly 319px negative space above and below.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:23:21 +03:00
Robin Kutesa
fd327423aa fix(icon): optical vertical centering — shift bars up 22px
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
Mathematical centre (512px) reads as low due to orange glow blooming
upward. Shifting the bar group up 22px balances perceived top/bottom
negative space (305px above, 349px below → visually equal).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:15:26 +03:00
Robin Kutesa
9cff253e6c fix(icon): centre each bar individually on the 1024px canvas
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
Each pill is now positioned at x = (1024 - bar_width) / 2,
giving symmetric left/right margins regardless of bar length.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:11:58 +03:00
Robin Kutesa
568eb82e5e Fix icon bar order: orange short, white longest, grey middle
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
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 22:08:52 +03:00
Robin Kutesa
576f10f54c Fix icon bar sizes: decreasing widths matching reference logo
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
Bars are now left-aligned with decreasing widths (600/440/300px),
matching the reference where the orange top bar is longest and each
gray bar steps down in width like a news feed preview.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 22:04:29 +03:00
Robin Kutesa
a44fcf9749 Replace app icon: three-bar signal feed design
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
Orange top bar + two gray bars on dark background. Cleaner and more
legible at small sizes (notification, Settings, Spotlight) than the
previous concentric-rings design.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 21:58:53 +03:00
Robin Kutesa
f5c9f4d9ae Remove orange dot overlay from i in JarvisWordmark
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
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 21:43:56 +03:00
Robin Kutesa
9129315594 Redesign JarvisWordmark: typewriter font, lowercase j, orange i-dot
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
- Switches from .system(.heavy) to Courier New Bold — classic typewriter look
- 'j' is now lowercase (was 'J'), still in KisaniOrange
- 'i' renders in white with an orange Circle overlaid at the tittle position,
  so the dot reads orange against the black background
- Implemented as HStack of individual Text segments so each character can
  be independently coloured; 'i' uses .overlay(alignment: .top) with a
  size-proportional Circle offset to sit at glyph-tittle height

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 21:31:12 +03:00
Robin Kutesa
053e6341ce Add share / save / mark-read toolbar buttons to detail and reader views
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
Both StoryDetailView and ArticleReaderView now show three icon buttons in
the navigation bar trailing area:
- Share (square.and.arrow.up) — story headline+summary+URL / article
  headline+sourceUrl, passed to UIActivityViewController via ShareSheet.
- Save (bookmark / bookmark.fill, orange when active) — toggles SavedStory
  in SwiftData; StoryDetailView also ensures a CachedStory exists so the
  Saved tab can display it offline.
- Mark read/unread (checkmark.circle / checkmark.circle.fill, orange when
  read) — toggle mirroring the swipe action already present in the feed.

The read state in StoryDetailView is still auto-marked on open (.task
→ markRead()); the toolbar button lets users undo that without going back
to the feed. ArticleReaderView targets the parent story (route.storyId).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 20:50:22 +03:00
54 changed files with 2726 additions and 1127 deletions

View File

@@ -1,14 +1,15 @@
# Gitea Actions reads this path (.github/workflows) as well as .gitea/workflows.
# iOS builds require a macOS runner: register a Gitea act_runner on a Mac with
# labels matching `runs-on` below (macos-14 / macos-15), or change them to your
# runner's label. On GitHub-hosted runners these labels work as-is.
# Gitea Actions reads .github/workflows/ (as well as .gitea/workflows/).
# Keeping everything here avoids running two duplicate pipelines.
#
# Runner requirement: a Mac with Xcode 16+ registered as a Gitea act_runner
# with labels "self-hosted" and "macos" (see CONTRIBUTING.md → Runner Setup).
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
branches: [main, develop]
push:
branches: [develop]
concurrency:
group: ci-${{ github.ref }}
@@ -16,13 +17,12 @@ concurrency:
jobs:
build:
name: Build · ${{ matrix.os }} · ${{ matrix.configuration }}
runs-on: ${{ matrix.os }}
name: Build · ${{ matrix.configuration }}
runs-on: [self-hosted, macos]
strategy:
fail-fast: false
matrix:
os: [ macos-14, macos-15 ]
configuration: [ Debug, Release ]
configuration: [Debug, Release]
steps:
- uses: actions/checkout@v4
@@ -31,21 +31,29 @@ jobs:
run: xcodebuild -version
- name: Install XcodeGen
run: brew install xcodegen
run: brew list xcodegen &>/dev/null || brew install xcodegen
- name: Generate Xcode project
run: xcodegen generate
- name: Build (${{ matrix.configuration }}, iOS Simulator)
# Device SDK, signing disabled: CoreSimulator on the runner Mac is unreliable
# (Xcode on an external volume can't discover simulator runtimes and hangs).
# Compilation coverage is identical; no tests run on simulator yet anyway.
- name: Build (${{ matrix.configuration }}, iOS device SDK)
run: |
set -o pipefail
xcodebuild \
-project Jarvis.xcodeproj \
-scheme Jarvis \
-project Jervis.xcodeproj \
-scheme Jervis \
-configuration ${{ matrix.configuration }} \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
-sdk iphoneos \
-destination 'generic/platform=iOS' \
-derivedDataPath build \
COMPILATION_CACHE_ENABLE_CACHING=NO \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
build
# No test targets exist yet (see docs/BACKLOG.md #13). When a JervisTests
# target is added, switch this to `xcodebuild test` on a simulator — after
# fixing CoreSimulator on the runner (Xcode must live on the boot volume).

65
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
# Triggers on merge to main. Archives the app and exports an IPA.
# TestFlight upload requires three secrets set in:
# Gitea → Repository Settings → Actions → Secrets
#
# ASC_KEY_ID — App Store Connect API key ID (e.g. ABC123DEFG)
# ASC_ISSUER_ID — Issuer ID from App Store Connect → Users → API Keys
# ASC_PRIVATE_KEY — Full contents of the downloaded .p8 file
#
# The runner Mac must have the distribution certificate and team K8BLMMR883
# provisioning profile in its keychain (set up once in Xcode on that machine).
name: Release
on:
push:
branches: [main]
jobs:
archive:
name: Archive & Export IPA
runs-on: [self-hosted, macos]
steps:
- uses: actions/checkout@v4
- name: Xcode version
run: xcodebuild -version
- name: Install XcodeGen
run: brew list xcodegen &>/dev/null || brew install xcodegen
- name: Generate Xcode project
run: xcodegen generate
- name: Archive (Release, device)
run: |
set -o pipefail
xcodebuild archive \
-project Jervis.xcodeproj \
-scheme Jervis \
-configuration Release \
-sdk iphoneos \
-archivePath build/Jervis.xcarchive \
DEVELOPMENT_TEAM=K8BLMMR883
- name: Export IPA
run: |
set -o pipefail
xcodebuild -exportArchive \
-archivePath build/Jervis.xcarchive \
-exportPath build/ipa \
-exportOptionsPlist ExportOptions.plist
- name: Upload to TestFlight
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_PRIVATE_KEY: ${{ secrets.ASC_PRIVATE_KEY }}
run: |
echo "$ASC_PRIVATE_KEY" > /tmp/AuthKey_${ASC_KEY_ID}.p8
xcrun altool --upload-app \
--type ios \
--file build/ipa/Jervis.ipa \
--apiKey "$ASC_KEY_ID" \
--apiIssuer "$ASC_ISSUER_ID"
rm -f /tmp/AuthKey_${ASC_KEY_ID}.p8

2
.gitignore vendored
View File

@@ -7,7 +7,7 @@ xcuserdata/
*.moved-aside
# Generated by XcodeGen — project.yml is the source of truth
Jarvis.xcodeproj/
Jervis.xcodeproj/
# SwiftPM
.build/

200
CLAUDE.md
View File

@@ -1,53 +1,165 @@
# Jarvis — Claude Code Handoff
# Jarvis iOS — Claude Code
## What this is
Jarvis is an iOS app (SwiftUI + SwiftData) that connects to a self-hosted RSS
correlation platform. It displays news stories ranked by signal score, caches
full article content for offline reading, and receives live updates over WebSocket.
Jarvis is an iOS app (SwiftUI, SwiftData, iOS 17+) that displays ranked news stories
served from a self-hosted backend. The backend ingests RSS, clusters articles into
stories, ranks them by signal score, and serves cached results via REST + WebSocket.
## Architecture
- REST on launch → WebSocket for live updates
- Server computes signal scores — app only renders them
- Full offline support via SwiftData cache
- No auth — local network / self-hosted only
The app is a **cached news intelligence client**. It is not an RSS reader.
## Files produced so far
- `API/contract.md` — full REST + WebSocket API spec
- `Jarvis/Models/Models.swift` — all Codable structs + SwiftData models
- `Jarvis/Networking/APIClient.swift` — REST layer (actor-based)
- `Jarvis/Networking/WebSocketManager.swift` — WS connection + reconnect backoff
- `Jarvis/Store/StoryStore.swift` — central ObservableObject, drives all views
- `Jarvis/Store/ServerSettings.swift` — persists server host to UserDefaults
- `Jarvis/JarvisApp.swift` — app entry point, SwiftData container
- `Jarvis/Views/RootTabView.swift` — tab navigation
- `Jarvis/Views/Onboarding/OnboardingView.swift` — server setup screen
---
## Views still needed
- `SignalFeedView` — home screen, story list sorted by signal score
- `StoryDetailView` — cluster detail, timeline, consensus/conflict
- `ArticleReaderView` — full article, cached badge, more from cluster
- `FeedManagerView` — feed list, health states, add/delete
- `LatestView`, `SavedView`, `SearchView` — secondary tabs
## Architecture rule
```
RSS feeds → backend ingestion → cache/database → API → frontend
```
**The frontend must never fetch RSS feeds directly.**
**The frontend must never parse, deduplicate, or rank articles.**
**The frontend consumes cached backend data only.**
---
## Backend responsibilities (not the app's job)
- RSS fetching, validation, timeout handling
- Feed health checks and dead-feed detection
- Article normalization and deduplication
- Story clustering and ranking
- Freshness, interestingness, and signal scoring
- API response caching and last-known-good fallback
The app trusts these to be done. It does not replicate them.
---
## Frontend responsibilities
- Load cached stories immediately on open (SwiftData)
- Display stories with freshness indicators and source attribution
- Show connection/refresh state without blocking the UI
- Show stale-cache label when backend data is old
- Paginate via cursor (`nextCursor`)
- Never show an empty state when the cache has data
**Performance targets:**
- App opens with cached news: instant
- Home feed API response: < 1 second
- Refresh response: < 2 seconds
- Story open: < 500ms
- Background refresh does not block UI
If any of these fail, treat it as a bug.
---
## Frontend validation checklist
Before marking any feed/story feature complete, verify:
- [ ] Cached news loads immediately without waiting for RSS
- [ ] Refresh does not freeze the UI
- [ ] Empty state does not appear when cache has data
- [ ] Stale cache is labeled (age visible)
- [ ] Fresh stories rise above old ones
- [ ] Categories are balanced across the feed
- [ ] Source attribution is correct and links work
- [ ] Offline state shows last-known-good content
---
## Project structure
```
Jarvis/
Models/Models.swift — Codable structs + SwiftData models
Networking/APIClient.swift — REST layer (actor-based)
Networking/WebSocketManager.swift — WS + reconnect backoff
Store/StoryStore.swift — central ObservableObject, drives all views
Store/StoryStore+Supplement.swift — background section supplement fetches
Store/ServerSettings.swift — persists host to UserDefaults
Store/CacheMaintenance.swift — SwiftData eviction
Views/
Home/SignalFeedView.swift — main feed (All digest, pill pills, sub-sections)
Home/StoryRowView.swift — story card
Reader/ArticleReaderView.swift — full article, cached badge
Story/StoryDetailView.swift — cluster detail + timeline
Feeds/FeedManagerView.swift — feed list + health states
Feeds/AddFeedSheet.swift
Settings/SettingsView.swift
Settings/NotificationsView.swift
Shared/Theme.swift — StoryPill enum, NewsSection, design tokens
Shared/SignalStripe.swift
Shared/SourceChips.swift
Shared/CachedBadge.swift
Shared/ConnectionBanner.swift
Onboarding/OnboardingView.swift
JarvisApp.swift
API/contract.md — REST + WebSocket API spec
```
---
## Design tokens
- Primary accent: `KisaniOrange` = #FF5C00 (add to Assets.xcassets)
- Background: pure black #000000
- Surface: #111111, #1A1A1A
- All views: `.preferredColorScheme(.dark)`
## Key decisions already made
- Story = user-facing product object (never expose Cluster to UI)
- Pipeline: Article → Cluster → Story
- Signal score is server-computed, breakdown exposed as `scoreBreakdown`
- WebSocket pushes lightweight events (storyId + score only), app refetches full object via REST
- WS reconnect: exponential backoff 1s → 2s → 4s → max 60s
- Pagination: cursor-based via `nextCursor`
- Accent: `KisaniOrange` = `#FF5C00`
- Background: `#000000` (pure black)
- Surface: `#111111` / `#1A1A1A`
- All views: `.preferredColorScheme(.dark)` unless user overrides in settings
## To start in Claude Code
```bash
cd jarvis
# Create new Xcode project named Jarvis, iOS target
# Drag all .swift files into the project
# Add KisaniOrange (#FF5C00) to Assets.xcassets as a Color Set
# Set minimum deployment target to iOS 17 (SwiftData requirement)
```
---
## Key decisions
- `Story` is the user-facing object. Never expose `Cluster` to UI.
- Pipeline: `Article → Cluster → Story`
- Signal score is server-computed; `scoreBreakdown` is exposed for display.
- WebSocket pushes `{storyId, score}` only; app re-fetches the full object via REST.
- WS reconnect: exponential backoff 1s 2s 4s max 60s.
- Pagination: cursor-based via `nextCursor`.
- `StoryStore.setPill()` resets `lastSyncedAt` to bypass the 30s rate-limit on filter switch.
- `sectionSupplement` fetches populate sparse regional sections in the All digest independently.
- **Age must be read from `firstSeenAt`, never `updatedAt`.** The backend bumps `updatedAt`
every time it attaches another article to a cluster, however loosely related a story
can read "1 hr ago" while being weeks old (see `docs/BACKLOG.md` #15). `firstSeenAt` is
the honest age and is already decoded on `StorySummary`.
- **Stale-news control (client-side, `Views/Shared/Theme.swift`):** `clientRank` applies a
soft time-decay to `signalScore` so fresh stories outrank stale high-scorers in
`feedOrder` it computes age from `firstSeenAt`, not `updatedAt` (was silently broken
by the same bug above until fixed). `StorySummary.isFossil` (`firstSeenAt` > 5 days) is a
hard backstop on top of that: `SignalFeedView.mainStories` and `.makeDigest()` sink
fossils below current stories so one can never take the hero/lead slot, regardless of
score. Fossils still appear in the feed — just not in the most prominent position. This
is a display/ordering patch; the server's own ranking is still wrong at the source.
---
## Pill → tag mapping
| Pill | Backend tags queried |
|---|---|
| All | — (no filter, full ranked feed) |
| F1 | `formula-1` |
| Sport | `sports`, `esports` |
| Tech | `technology`, `artificial-intelligence`, `machine-learning`, `cloud-computing`, `homelab`, `cybersecurity`, `security`, `programming-and-software-development`, `privacy-and-data-protection`, `web-design-and-ui-ux`, `wordpress-and-web-development`, `robotics` |
| East Africa | `east-africa`, `uganda` (Uganda sub-section pinned first) |
| Southern Africa | `south-africa`, `namibia`, `botswana`, `zimbabwe`, `zambia`, `mozambique`, `southern-africa` |
| Canada | `canada` |
| US | `united-states` |
---
## Reporting requirement
At the end of any significant work session, generate a report covering:
1. **Executive summary** — is the app usable, fast, serving cached news?
2. **Issues found** — backend, frontend, cache, feed, ranking, data quality
3. **Fixes made** — file, function, reason, result
4. **Measurements** — before/after for response times, article counts, dead feeds
5. **Remaining risks** — nothing hidden
6. **Next actions** — highest-value improvements
7. **Status** — one of: `NOT READY` / `PARTIALLY READY` / `READY FOR TESTING` / `READY FOR RELEASE`
Never claim READY FOR RELEASE without evidence from logs, API responses, or timing measurements.

140
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,140 @@
# Contributing to Jervis
## Branch workflow
```
feature/* ──→ develop ──→ main
```
- **`main`** — protected. No direct pushes. Every change arrives via PR from `develop`.
- **`develop`** — integration branch. All feature work merges here first. CI runs on every push.
- **`feature/*`** — short-lived branches cut from `develop`.
## Starting work
```bash
git checkout develop
git pull origin develop
git checkout -b feature/your-description
```
## Opening a PR
1. Push your feature branch and open a PR targeting **`develop`**.
2. CI must pass (Debug + Release builds succeed).
3. At least 1 approval required before merge.
## Releasing to TestFlight
A maintainer opens a PR from `develop → main`. Merging triggers the release workflow:
archive → export IPA → upload to TestFlight.
---
## Runner setup (one-time, on your Mac)
Gitea Actions requires a self-hosted runner. The CI workflows target `[self-hosted, macos]`.
### 1. Enable Gitea Actions (admin, once per instance)
In `app.ini` on the Gitea server, add:
```ini
[actions]
ENABLED = true
```
Then restart Gitea.
### 2. Install act_runner on the Mac
There is no Homebrew formula — download the official binary:
```bash
curl -sL -o /usr/local/bin/act_runner \
https://dl.gitea.com/act_runner/0.6.1/act_runner-0.6.1-darwin-arm64
chmod +x /usr/local/bin/act_runner
```
### 3. Register the runner
Get a registration token from:
**Repository Settings → Actions → Runners → Create Runner**
(or via API: `GET /api/v1/repos/kutesir/jarvis/actions/runners/registration-token`)
The `:host` label suffix is required — it makes jobs run directly on the Mac
instead of in Docker (which xcodebuild needs, and the Mac has no Docker):
```bash
mkdir -p ~/.act_runner && cd ~/.act_runner
act_runner register --no-interactive \
--instance http://10.10.1.21:3002 \
--token <RUNNER_TOKEN> \
--name "mac-mini" \
--labels "self-hosted:host,macos:host"
```
### 4. Configure and start the runner
Generate the config, then fix two defaults that break Docker-less Macs
(the generated config ships Docker-based ubuntu labels that make the daemon
refuse to start without a Docker socket):
```bash
cd ~/.act_runner
act_runner generate-config > config.yaml
# 1. labels: [] — empty falls back to the :host labels in .runner
# 2. docker_host: "-" — never look for a Docker daemon
```
In `config.yaml`, set:
- `labels: []` (replace the default ubuntu docker labels)
- `docker_host: "-"` (under `container:`)
Then start it:
```bash
act_runner daemon --config ~/.act_runner/config.yaml
```
To keep it running across reboots, load it as a LaunchAgent
(`~/Library/LaunchAgents/com.gitea.act-runner.plist`, see repo scripts).
The runner must have **Xcode 16+** installed and accepted the license:
```bash
sudo xcodebuild -license accept
```
---
## Signing and TestFlight secrets
For the release workflow to archive and upload, the runner Mac needs:
1. The **iOS Distribution certificate** installed in its keychain (set up once in Xcode → Settings → Accounts → Manage Certificates).
2. Three **Gitea secrets** set under:
**Repository Settings → Actions → Secrets**
| Secret | Where to get it |
|---|---|
| `ASC_KEY_ID` | App Store Connect → Users & Access → API Keys |
| `ASC_ISSUER_ID` | Same page, above the key list |
| `ASC_PRIVATE_KEY` | The `.p8` file downloaded when you created the key (only downloadable once) |
---
## Branch protection (manual setup in Gitea UI)
Go to **Repository Settings → Branches → Add Branch Rule**:
| Setting | Value |
|---|---|
| Branch name pattern | `main` |
| Require pull request before merging | ✅ |
| Required approvals | 1 |
| Require status checks to pass | ✅ — select `Build · Debug` and `Build · Release` |
| Block force push | ✅ |
| Block deletion | ✅ |
Repeat with `develop` but set required approvals to 0 (CI gate only).

38
ExportOptions.plist Normal file
View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
method: "app-store" to upload to TestFlight/App Store.
"development" for ad-hoc device installs.
"ad-hoc" for distribution outside the App Store.
-->
<key>method</key>
<string>app-store</string>
<key>teamID</key>
<string>K8BLMMR883</string>
<!--
destination: "upload" sends directly to App Store Connect after export.
"export" writes the IPA locally only (upload manually or via altool).
-->
<key>destination</key>
<string>export</string>
<key>signingStyle</key>
<string>automatic</string>
<key>stripSwiftSymbols</key>
<true/>
<key>uploadBitcode</key>
<false/>
<key>compileBitcode</key>
<false/>
<key>thinning</key>
<string>&lt;none&gt;</string>
</dict>
</plist>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

View File

@@ -1,57 +0,0 @@
// JarvisApp.swift
import SwiftUI
import SwiftData
import UIKit
import BackgroundTasks
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
// Register the briefing background-refresh handler before launch completes.
BGTaskScheduler.shared.register(forTaskWithIdentifier: jarvisBriefingRefreshID, using: nil) { task in
Task { @MainActor in
await NotificationManager.shared.applySettings()
NotificationManager.shared.scheduleBackgroundRefresh()
task.setTaskCompleted(success: true)
}
}
return true
}
}
@main
struct JarvisApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@StateObject private var settings = ServerSettings.shared
@StateObject private var store = StoryStore.shared
@StateObject private var ws = WebSocketManager.shared
@StateObject private var connectivity = ConnectivitySettings.shared
@StateObject private var connManager = ConnectivityManager.shared
@StateObject private var notifications = NotificationManager.shared
var body: some Scene {
WindowGroup {
Group {
if settings.isConfigured {
RootTabView()
} else {
OnboardingView()
}
}
.environmentObject(settings)
.environmentObject(store)
.environmentObject(ws)
.environmentObject(connectivity)
.environmentObject(connManager)
.environmentObject(notifications)
.task {
connManager.start()
await connManager.resolveAndActivate()
await notifications.bootstrap()
}
}
.modelContainer(for: [CachedStory.self, CachedArticle.self, ReadStory.self,
SavedStory.self, SeenStory.self])
}
}

View File

@@ -1,158 +0,0 @@
// StoryStore.swift
// Jarvis central observable store. Views read from here, never hit the API directly.
import Foundation
import Combine
import SwiftData
@MainActor
final class StoryStore: ObservableObject {
static let shared = StoryStore()
@Published var stories: [StorySummary] = []
@Published var isLoading = false
@Published var isLoadingMore = false
@Published var error: APIError?
@Published var lastSyncedAt: Date?
@Published var hasMore = false
@Published var selectedTopic: String? = nil
private var nextCursor: String?
private var refreshTask: Task<Void, Never>?
private var cancellables = Set<AnyCancellable>()
private let ws = WebSocketManager.shared
private let api = APIClient.shared
private init() {
subscribeToWebSocket()
}
// MARK: - Load
func loadStories(refresh: Bool = false) async {
guard !isLoading else { return }
isLoading = true
error = nil
// Don't wipe the visible feed up-front. Only the cursor resets for a
// refresh; the list is replaced on success. A cancelled/failed refresh
// then keeps showing what we already have instead of blanking out.
let cursor = refresh ? nil : nextCursor
do {
let result = try await api.fetchStories(
cursor: cursor,
topic: selectedTopic
)
stories = refresh ? result.data : stories + result.data
nextCursor = result.nextCursor
hasMore = result.hasMore
lastSyncedAt = Date()
} catch let e as APIError where !e.isCancelled {
error = e
} catch {
// Benign cancellation (network blip / superseded request): keep state.
}
isLoading = false
}
func loadMore() async {
guard hasMore, !isLoadingMore, let cursor = nextCursor else { return }
isLoadingMore = true
do {
let result = try await api.fetchStories(cursor: cursor, topic: selectedTopic)
stories += result.data
nextCursor = result.nextCursor
hasMore = result.hasMore
} catch {}
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.
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)
}
}
func setTopic(_ topic: String?) async {
selectedTopic = topic
await loadStories(refresh: true)
}
// MARK: - WebSocket
private func subscribeToWebSocket() {
ws.events
.receive(on: DispatchQueue.main)
.sink { [weak self] event in
self?.handle(event: event)
}
.store(in: &cancellables)
}
private func handle(event: WSEvent) {
switch event.type {
case .storyUpdated:
guard let id = event.storyId,
let score = event.signalScore,
let count = event.sourceCount else { return }
updateStory(id: id, signalScore: score, sourceCount: count)
Task { await refetch(storyId: id) }
case .storyCreated:
scheduleCoalescedRefresh()
case .storyStale:
guard let id = event.storyId else { return }
removeStory(id: id)
case .feedHealth:
NotificationCenter.default.post(name: .feedHealthChanged, object: event)
default:
break
}
}
private func updateStory(id: String, signalScore: Int, sourceCount: Int) {
guard let idx = stories.firstIndex(where: { $0.id == id }) else { return }
let old = stories[idx]
let updated = StorySummary(
id: old.id,
headline: old.headline,
summary: old.summary,
topic: old.topic,
tags: old.tags,
signalScore: signalScore,
scoreBreakdown: old.scoreBreakdown,
sourceCount: sourceCount,
sources: old.sources,
consensus: old.consensus,
conflict: old.conflict,
updatedAt: old.updatedAt,
createdAt: old.createdAt
)
stories[idx] = updated
stories.sort(by: StorySummary.feedOrder)
}
private func removeStory(id: String) {
stories.removeAll { $0.id == id }
}
private func refetch(storyId: String) async {
// Lightweight: only update what changed via WS patch above.
// Full detail is fetched lazily when user taps into the story.
}
}
extension Notification.Name {
static let feedHealthChanged = Notification.Name("feedHealthChanged")
}

View File

@@ -1,250 +0,0 @@
// ArticleReaderView.swift
// Jarvis full article. Caches to SwiftData on load; reads from cache offline.
import SwiftUI
import SwiftData
@MainActor
final class ArticleReaderViewModel: ObservableObject {
@Published var article: Article?
@Published var siblings: [TimelineEntry] = []
@Published var isLoading = false
@Published var error: String?
private let api = APIClient.shared
func load(route: ArticleRoute, context: ModelContext) async {
isLoading = true
error = nil
do {
let art = try await api.fetchArticle(id: route.articleId)
article = art
cache(art, context: context)
if let detail = try? await api.fetchStory(id: route.storyId) {
siblings = detail.timeline
.filter { $0.articleId != route.articleId }
.sorted { $0.publishedAt < $1.publishedAt }
}
} catch {
// Offline: fall back to the SwiftData cache.
loadCached(route: route, context: context)
if article == nil {
self.error = (error as? APIError)?.errorDescription ?? error.localizedDescription
}
}
isLoading = false
}
private func cache(_ art: Article, context: ModelContext) {
let id = art.id
let existing = try? context.fetch(
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.id == id })
)
if existing?.first == nil {
context.insert(CachedArticle(from: art))
try? context.save()
}
}
private func loadCached(route: ArticleRoute, context: ModelContext) {
let id = route.articleId
if let cached = (try? context.fetch(
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.id == id })
))?.first {
article = Article(cached: cached)
}
let sid = route.storyId
let arts = (try? context.fetch(
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.storyId == sid })
)) ?? []
siblings = arts
.filter { $0.id != route.articleId }
.map { TimelineEntry(articleId: $0.id, source: $0.source, headline: $0.headline,
publishedAt: $0.publishedAt, isBreaking: false) }
}
}
struct ArticleReaderView: View {
let route: ArticleRoute
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@StateObject private var vm = ArticleReaderViewModel()
@Query private var cachedArticles: [CachedArticle]
private var article: Article? { vm.article }
private var isCached: Bool { cachedArticles.contains { $0.id == route.articleId } }
private var backTitle: String {
let h = route.parentHeadline
return h.count > 30 ? String(h.prefix(30)).trimmingCharacters(in: .whitespaces) + "" : h
}
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
ScrollView {
if let article {
content(article)
} else if vm.isLoading {
ProgressView().tint(Palette.orange).padding(.top, 80)
} else {
Text(vm.error ?? "Article unavailable.")
.font(.system(size: 14))
.foregroundStyle(Color(hex: "555555"))
.padding(.top, 80)
}
}
}
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .topBarLeading) { backButton }
}
.toolbarBackground(Color.black, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.preferredColorScheme(.dark)
.task { await vm.load(route: route, context: modelContext) }
}
private var backButton: some View {
Button { dismiss() } label: {
HStack(spacing: 4) {
Image(systemName: "chevron.left").font(.system(size: 15, weight: .bold))
Text(backTitle).font(.system(size: 16, weight: .regular)).lineLimit(1)
}
.foregroundStyle(Palette.orange)
}
}
@ViewBuilder
private func content(_ article: Article) -> some View {
VStack(alignment: .leading, spacing: 16) {
// Source pill + timestamp + cached badge
HStack(spacing: 10) {
Text(article.source)
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 10).padding(.vertical, 5)
.background(Palette.orange)
.clipShape(Capsule())
Text(article.publishedAt.clockShort)
.font(.system(size: 12, weight: .regular, design: .monospaced))
.foregroundStyle(Color(hex: "666666"))
if isCached { CachedBadge(text: "Cached") }
Spacer()
}
Text(article.headline)
.font(.system(size: 26, weight: .heavy))
.foregroundStyle(.white)
.fixedSize(horizontal: false, vertical: true)
heroImage(article.imageUrl)
if let author = article.author, !author.isEmpty {
Text("By \(author)")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(Color(hex: "777777"))
}
Text(article.body)
.font(.system(size: 16, weight: .regular))
.foregroundStyle(Palette.bodyText) // #4A4A4A
.lineSpacing(10) // ~1.65 line-height at 16pt
.fixedSize(horizontal: false, vertical: true)
if !vm.siblings.isEmpty {
Divider().overlay(Palette.hairline).padding(.vertical, 8)
clusterSection
}
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
}
private func heroImage(_ urlString: String?) -> some View {
let fallback = RoundedRectangle(cornerRadius: 10).fill(Palette.surface2)
return Group {
if let urlString, let url = URL(string: urlString) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().aspectRatio(contentMode: .fill)
case .empty:
fallback.overlay(ProgressView().tint(Color(hex: "555555")))
case .failure:
fallback.overlay(Image(systemName: "photo")
.font(.system(size: 26)).foregroundStyle(Color(hex: "3A3A3A")))
@unknown default:
fallback
}
}
} else {
fallback.overlay(Image(systemName: "photo")
.font(.system(size: 26)).foregroundStyle(Color(hex: "3A3A3A")))
}
}
.frame(height: 200)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
private var clusterSection: some View {
VStack(alignment: .leading, spacing: 0) {
Text("MORE FROM THIS CLUSTER")
.font(.system(size: 10, weight: .bold, design: .monospaced))
.kerning(0.8)
.foregroundStyle(Color(hex: "555555"))
.padding(.bottom, 12)
ForEach(vm.siblings.prefix(3)) { entry in
NavigationLink(value: ArticleRoute(articleId: entry.articleId,
storyId: route.storyId,
parentHeadline: route.parentHeadline)) {
clusterRow(entry)
}
.buttonStyle(.plain)
}
}
}
private func clusterRow(_ entry: TimelineEntry) -> some View {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text(entry.source)
.font(.system(size: 12, weight: .bold))
.foregroundStyle(Palette.orange)
Spacer()
Text(entry.publishedAt.clockShort)
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(Color(hex: "555555"))
}
Text(entry.headline)
.font(.system(size: 15, weight: .regular))
.foregroundStyle(Color(hex: "CFCFCF"))
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.leading)
}
.padding(.vertical, 12)
.overlay(alignment: .bottom) { Rectangle().fill(Palette.hairline).frame(height: 0.5) }
.contentShape(Rectangle())
}
}
// Reconstruct an Article from its cached copy for offline display.
extension Article {
init(cached: CachedArticle) {
self.init(
id: cached.id,
storyId: cached.storyId,
source: cached.source,
sourceUrl: "",
headline: cached.headline,
body: cached.body,
imageUrl: cached.imageUrl,
author: cached.author,
publishedAt: cached.publishedAt
)
}
}

View File

@@ -1,262 +0,0 @@
// Theme.swift
// Jarvis shared design tokens, signal-score fade math, formatting, nav routes.
// Every hex value here is from the spec; nothing is invented.
import SwiftUI
// MARK: - Hex colors
extension Color {
init(hex: String) {
let s = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
var v: UInt64 = 0
Scanner(string: s).scanHexInt64(&v)
let r, g, b: Double
switch s.count {
case 6:
r = Double((v >> 16) & 0xFF) / 255
g = Double((v >> 8) & 0xFF) / 255
b = Double(v & 0xFF) / 255
default:
r = 0; g = 0; b = 0
}
self = Color(.sRGB, red: r, green: g, blue: b, opacity: 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")
// health
static let healthActive = Color(hex: "2A5A2A")
static let healthFailing = Color(hex: "AA6600")
static let healthDead = Color(hex: "5A1A1A")
// 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 cachedGreen = Color(hex: "2A5A2A")
static let bodyText = Color(hex: "4A4A4A")
}
// MARK: - Signal-score fade
//
// Spec anchors:
// stripe : 97 #FF5C00, fades to #1A1A1A at 0
// title : 97 white, 20 #444444, 019 near invisible
// score : 80100 full orange · 5079 dimmed orange · 2049 dark brown · 019 near invisible
enum Signal {
private static func lerp(_ a: (Double, Double, Double),
_ b: (Double, Double, Double),
_ t: Double) -> Color {
let t = max(0, min(1, t))
return Color(.sRGB,
red: a.0 + (b.0 - a.0) * t,
green: a.1 + (b.1 - a.1) * t,
blue: a.2 + (b.2 - a.2) * t,
opacity: 1)
}
/// 3pt left stripe color: #1A1A1A (0) #FF5C00 (97+).
static func stripeColor(_ score: Int) -> Color {
let t = Double(score) / 97.0
return lerp((0x1A/255, 0x1A/255, 0x1A/255), // #1A1A1A
(0xFF/255, 0x5C/255, 0x00/255), // #FF5C00
t)
}
/// Story title color: white (97+) #444444 (20) near invisible (0).
static func titleColor(_ score: Int) -> Color {
if score >= 97 { return .white }
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)
}
// 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)
}
/// Signal score number color: stays in orange/brown hue, fades with score.
/// near-invisible brown (0) full #FF5C00 (100).
static func scoreColor(_ score: Int) -> Color {
let t = Double(score) / 100.0
return lerp((0x2A/255, 0x18/255, 0x10/255), // near-invisible brown
(0xFF/255, 0x5C/255, 0x00/255), // #FF5C00
t)
}
}
// MARK: - Topic display
enum Topic {
static func label(_ slug: String) -> String {
switch slug.lowercased() {
case "ai", "artificial-intelligence": return "AI"
case "us", "united-states": return "US"
case "formula-1": return "Formula 1"
case "ui-ux", "web-design-and-ui-ux": return "Web Design"
default:
// Slug Title Case, e.g. "cloud-computing" "Cloud Computing".
return slug.split(separator: "-")
.map { $0.prefix(1).uppercased() + $0.dropFirst() }
.joined(separator: " ")
}
}
/// Topic pills shown on the home screen.
static let filters: [(label: String, slug: String?)] = [
("All", nil),
("Finance", "finance"),
("Tech", "tech"),
("Politics", "politics"),
("Africa", "africa"),
]
}
// MARK: - Time formatting
extension Date {
/// Short relative string e.g. "just now", "3 min", "2 hr", "4 d".
func timeAgoShort(reference: Date = Date()) -> String {
let secs = max(0, Int(reference.timeIntervalSince(self)))
switch secs {
case 0..<60: return "just now"
case 60..<3600: return "\(secs / 60) min"
case 3600..<86400: return "\(secs / 3600) hr"
default: return "\(secs / 86400) d"
}
}
/// "08:03" style monospaced clock for timeline / source chips.
var clockShort: String {
let f = DateFormatter()
f.dateFormat = "HH:mm"
return f.string(from: self)
}
}
func syncedMinutesAgo(_ date: Date?, reference: Date = Date()) -> String {
guard let date else { return "never" }
return date.timeAgoShort(reference: reference)
}
// MARK: - Navigation routes
/// Route into the article reader. Carries the parent story context so the
/// back button can show the truncated headline.
struct ArticleRoute: Hashable {
let articleId: String
let storyId: String
let parentHeadline: String
}
// MARK: - Stable feed ordering
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.
static func feedOrder(_ a: StorySummary, _ b: StorySummary) -> Bool {
a.signalScore != b.signalScore ? a.signalScore > b.signalScore : a.id > b.id
}
}
// MARK: - Story pills (backend-tag driven)
//
// The backend classifies each story into canonical category slugs in `tags[]`.
// The client filters by pill -> slug membership only; no headline keyword
// matching. `topic` is a fallback while the backend finishes emitting `tags[]`.
enum StoryPill: String, CaseIterable, Identifiable {
case all = "All"
case f1 = "F1"
case sport = "Sport"
case ai = "AI"
case cloud = "Cloud"
case homelab = "HomeLab"
case tech = "Tech"
case uganda = "Uganda"
case southAfrica = "South Africa"
case canada = "Canada"
case unitedStates = "US"
var id: String { rawValue }
var label: String { rawValue }
var slugs: Set<String> {
switch self {
case .all: return []
case .f1: return ["formula-1"]
case .sport: return ["sports", "esports"]
case .ai: return ["artificial-intelligence", "machine-learning", "robotics"]
case .cloud: return ["cloud-computing"]
case .homelab: return ["homelab"]
case .tech: return ["technology", "programming-and-software-development",
"cybersecurity", "security", "privacy-and-data-protection",
"web-design-and-ui-ux", "wordpress-and-web-development"]
case .uganda: return ["uganda"]
case .southAfrica: return ["south-africa"]
case .canada: return ["canada"]
case .unitedStates: return ["united-states"]
}
}
}
extension StorySummary {
/// Backend `tags[]` are the source of truth; fall back to `topic` during the
/// transition while the backend finishes emitting tags.
var effectiveTags: Set<String> {
let clean = Set((tags ?? []).filter { !$0.isEmpty })
return clean.isEmpty ? [topic] : clean
}
func matches(_ pill: StoryPill) -> Bool {
pill == .all || !effectiveTags.isDisjoint(with: pill.slugs)
}
func inSection(_ section: NewsSection) -> Bool {
!effectiveTags.isDisjoint(with: section.slugs)
}
}
// MARK: - News sections (the "All" front page)
//
// Groups the firehose into Apple/Google-News-style sections. A story lands in
// the first section (by this order) whose slugs it carries; `pill` powers "See all".
struct NewsSection: Identifiable {
let id: String
let label: String
let slugs: Set<String>
let pill: StoryPill?
static let sections: [NewsSection] = [
.init(id: "us", label: "US", slugs: ["united-states"], pill: .unitedStates),
.init(id: "world", label: "World", slugs: ["world-news", "news", "news-and-current-affairs"], pill: nil),
.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: "ai", label: "AI", slugs: ["artificial-intelligence", "machine-learning", "robotics"], pill: .ai),
.init(id: "technology", label: "Technology", slugs: ["technology", "programming-and-software-development", "cybersecurity", "security", "web-design-and-ui-ux", "wordpress-and-web-development", "privacy-and-data-protection", "cloud-computing"], pill: .tech),
.init(id: "africa", label: "Africa", slugs: ["africa", "east-africa", "south-africa", "uganda"], 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),
.init(id: "health", label: "Health", slugs: ["health", "health-and-wellness", "mental-health"], pill: nil),
.init(id: "lifestyle", label: "Lifestyle", slugs: ["lifestyle", "food-and-drink", "travel", "gardening", "outdoor-and-hiking", "parenting-and-family", "interior-design-and-home-decor", "photography", "self-improvement-and-personal-development", "education", "e-learning"], pill: nil),
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "JervisIcon.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

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,11 +5,12 @@
<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>
<key>CFBundleDisplayName</key>
<string>Jarvis</string>
<string>Jervis</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -21,9 +22,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>tailscale</string>
@@ -38,7 +39,7 @@
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Jarvis connects to your self-hosted news platform on the local network.</string>
<string>Jervis connects to your self-hosted news platform on the local network.</string>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
@@ -53,7 +54,5 @@
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
</dict>
</plist>

150
Jervis/JarvisApp.swift Normal file
View File

@@ -0,0 +1,150 @@
// JarvisApp.swift
import SwiftUI
import SwiftData
import UIKit // needed for AppDelegate
import BackgroundTasks
// MARK: - Splash
struct SplashView: View {
var onDismiss: () -> Void
@State private var iconScale: CGFloat = 0.82
@State private var iconOpacity: Double = 0
@State private var markOpacity: Double = 0
@State private var rootOpacity: Double = 1
var body: some View {
ZStack {
Color(hex: "0A0A0A").ignoresSafeArea()
VStack(spacing: 22) {
Image("JervisIcon")
.resizable()
.frame(width: 108, height: 108)
.clipShape(Circle())
.shadow(color: Palette.orange.opacity(0.35), radius: 22, y: 4)
.scaleEffect(iconScale)
.opacity(iconOpacity)
JarvisWordmark(size: 28)
.opacity(markOpacity)
}
}
.opacity(rootOpacity)
.onAppear {
withAnimation(.spring(response: 0.48, dampingFraction: 0.74).delay(0.08)) {
iconScale = 1
iconOpacity = 1
}
withAnimation(.easeOut(duration: 0.28).delay(0.32)) {
markOpacity = 1
}
withAnimation(.easeIn(duration: 0.26).delay(1.6)) {
rootOpacity = 0
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.9) {
onDismiss()
}
}
}
}
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
// Briefing notifications refresh
BGTaskScheduler.shared.register(forTaskWithIdentifier: jarvisBriefingRefreshID, using: nil) { task in
Task { @MainActor in
await NotificationManager.shared.applySettings()
NotificationManager.shared.scheduleBackgroundRefresh()
task.setTaskCompleted(success: true)
}
}
// Story feed cache refresh
BGTaskScheduler.shared.register(forTaskWithIdentifier: jarvisFeedRefreshID, using: nil) { task in
guard let refresh = task as? BGAppRefreshTask else {
task.setTaskCompleted(success: false); return
}
BackgroundRefreshManager.handleFeedRefresh(task: refresh)
}
return true
}
}
@main
struct JarvisApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .dark
@StateObject private var settings = ServerSettings.shared
@StateObject private var store = StoryStore.shared
@StateObject private var ws = WebSocketManager.shared
@StateObject private var connectivity = ConnectivitySettings.shared
@StateObject private var connManager = ConnectivityManager.shared
@StateObject private var notifications = NotificationManager.shared
@Environment(\.scenePhase) private var scenePhase
@State private var splashDone = false
// Single ModelContainer instance shared with BackgroundRefreshManager.
private let container: ModelContainer = {
let schema = Schema([CachedStory.self, CachedArticle.self,
ReadStory.self, SavedStory.self, SeenStory.self])
return try! ModelContainer(for: schema)
}()
var body: some Scene {
WindowGroup {
ZStack {
// Main app always rendered underneath so it's ready when splash fades.
Group {
if settings.isConfigured {
RootTabView()
} else {
OnboardingView()
}
}
if !splashDone {
SplashView { splashDone = true }
}
}
.preferredColorScheme(appearanceMode.colorScheme)
.environmentObject(settings)
.environmentObject(store)
.environmentObject(ws)
.environmentObject(connectivity)
.environmentObject(connManager)
.environmentObject(notifications)
.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()
store.startForegroundSync()
}
.onChange(of: scenePhase) { _, phase in
switch phase {
case .active:
store.startForegroundSync()
// 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()
default:
break
}
}
}
.modelContainer(container)
}
}

View File

@@ -61,6 +61,11 @@ struct StorySummary: Codable, Identifiable, Hashable {
let conflict: String?
let updatedAt: Date
let createdAt: Date
/// When the cluster first appeared. `updatedAt` bumps on every article the
/// backend attaches to the cluster (even a loosely-related one), so it can
/// read "1 hr ago" on stories that are actually weeks old `firstSeenAt`
/// is the honest age. Optional so decoding tolerates older API responses.
let firstSeenAt: Date?
}
struct ScoreBreakdown: Codable, Hashable {
@@ -136,6 +141,10 @@ struct PaginatedStories: Codable {
let nextCursor: String?
let hasMore: Bool
let total: Int
let unreadCount: Int?
let readSuppressedCount: Int?
let lastReadSyncAt: Date?
let activePill: String?
}
// MARK: - WebSocket Events
@@ -178,6 +187,13 @@ 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()
/// When the cluster first appeared the honest age, unlike `updatedAt` which
/// bumps whenever the backend attaches another article. Defaults to `updatedAt`
/// for rows cached before this field was added.
var firstSeenAt: Date = Date()
var cachedAt: Date
init(from summary: StorySummary) {
@@ -191,6 +207,8 @@ final class CachedStory {
self.consensus = summary.consensus
self.conflict = summary.conflict
self.updatedAt = summary.updatedAt
self.createdAt = summary.createdAt
self.firstSeenAt = summary.firstSeenAt ?? summary.updatedAt
self.cachedAt = Date()
}
}

View File

@@ -33,23 +33,37 @@ private struct APIErrorResponse: Decodable {
let error: Inner
}
private struct EmptyResponse: Decodable {}
actor APIClient {
static let shared = APIClient()
private var baseURL: URL?
private let session: URLSession
private let decoder: JSONDecoder
private let deviceId: String
private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 10
self.session = URLSession(configuration: config)
self.deviceId = APIClient.loadDeviceId()
self.decoder = JSONDecoder()
self.decoder.dateDecodingStrategy = .iso8601
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
}
private static func loadDeviceId() -> String {
let key = "jarvis.device.id"
if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
return existing
}
let value = UUID().uuidString.lowercased()
UserDefaults.standard.set(value, forKey: key)
return value
}
func configure(host: String) throws {
guard let url = URL(string: "http://\(host)/api/v1") else {
throw APIError.invalidURL
@@ -68,12 +82,15 @@ actor APIClient {
func fetchStories(
cursor: String? = nil,
topic: String? = nil,
tags: Set<String> = [],
minSignal: Int? = nil,
limit: Int = 20
limit: Int = 20,
includeRead: Bool = false
) async throws -> PaginatedStories {
var params: [String: String] = ["limit": "\(limit)"]
var params: [String: String] = ["limit": "\(limit)", "include_read": includeRead ? "true" : "false"]
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)
}
@@ -82,6 +99,28 @@ actor APIClient {
try await get("/stories/\(id)")
}
func markStoryRead(id: String) async throws {
try await postVoid("/stories/\(id)/read", body: [
"device_id": deviceId,
"read_at": ISO8601DateFormatter().string(from: Date()),
"source": "ios",
])
}
func markStoryUnread(id: String) async throws {
try await delete("/stories/\(id)/read")
}
func markAllRead(storyIds: [String], pill: String) async throws {
try await postVoid("/me/read/bulk", body: [
"storyIds": storyIds,
"readAt": ISO8601DateFormatter().string(from: Date()),
"source": "mark_all_as_read",
"pill": pill,
"deviceId": deviceId,
])
}
// MARK: - Articles
func fetchArticle(id: String) async throws -> Article {
@@ -116,6 +155,7 @@ actor APIClient {
guard let url = components.url else { throw APIError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id")
return try await execute(request)
}
@@ -124,16 +164,32 @@ actor APIClient {
let url = base.appendingPathComponent(path)
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
return try await execute(request)
}
private func postVoid(_ path: String, body: [String: Any]) async throws {
guard let base = baseURL else { throw APIError.notConnected }
let url = base.appendingPathComponent(path)
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (_, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw APIError.serverError(code: "post_failed", message: "Post failed", status: 500)
}
}
private func delete(_ path: String) async throws {
guard let base = baseURL else { throw APIError.notConnected }
let url = base.appendingPathComponent(path)
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id")
let (_, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 204 else {
throw APIError.serverError(code: "delete_failed", message: "Delete failed", status: 500)

View File

@@ -5,7 +5,7 @@ import Foundation
import Combine
enum ConnectionState {
case disconnected
case disconnected
case connecting
case connected
case reconnecting(attempt: Int)
@@ -60,13 +60,16 @@ final class WebSocketManager: ObservableObject {
}
private func listen() {
task?.receive { [weak self] result in
guard let self else { return }
// Capture task locally so the Sendable receive closure doesn't reference
// the @MainActor-isolated property directly.
guard let t = task else { return }
t.receive { [weak self] result in
switch result {
case .failure:
Task { @MainActor in self.handleDisconnect() }
Task { @MainActor [weak self] in self?.handleDisconnect() }
case .success(let message):
Task { @MainActor in
Task { @MainActor [weak self] in
guard let self else { return }
self.handle(message: message)
self.listen()
}
@@ -99,9 +102,13 @@ final class WebSocketManager: ObservableObject {
private func startPing() {
pingTimer?.invalidate()
pingTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
self?.task?.sendPing { error in
if error != nil {
Task { @MainActor in self?.handleDisconnect() }
// Hop to MainActor before touching the isolated `task` property.
Task { @MainActor [weak self] in
guard let self else { return }
self.task?.sendPing { [weak self] error in
if error != nil {
Task { @MainActor [weak self] in self?.handleDisconnect() }
}
}
}
}
@@ -119,10 +126,10 @@ final class WebSocketManager: ObservableObject {
let delay = min(pow(2.0, Double(attempt - 1)), maxBackoff)
connectionState = .reconnecting(attempt: attempt)
reconnectTask = Task {
reconnectTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard !Task.isCancelled else { return }
await MainActor.run { self.openConnection() }
await MainActor.run { self?.openConnection() }
}
}
}

View File

@@ -15,7 +15,6 @@ import BackgroundTasks
enum BriefingPeriod { case morning, evening
var greeting: String { self == .morning ? "Good morning" : "Good evening" }
var emoji: String { self == .morning ? "☀️" : "🌙" }
var requestID: String { self == .morning ? "briefing.morning" : "briefing.evening" }
}
@@ -41,6 +40,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()
}
@@ -110,7 +114,7 @@ final class NotificationManager: NSObject, ObservableObject {
/// Build the "Jarvis" briefing: greeting + weather/rain + top stories to watch.
private func buildBriefing(_ period: BriefingPeriod) async -> UNMutableNotificationContent {
let content = UNMutableNotificationContent()
content.title = "\(period.greeting) \(period.emoji)"
content.title = period.greeting
content.sound = .default
var lines: [String] = []
@@ -123,7 +127,7 @@ final class NotificationManager: NSObject, ObservableObject {
let top = await topStories(limit: 3)
if !top.isEmpty {
lines.append("📣 Needs your attention:")
lines.append("Needs your attention:")
for s in top { lines.append("\(s.headline) (\(s.signalScore))") }
} else if lines.isEmpty {
lines.append("No new high-signal stories right now.")

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

@@ -0,0 +1,151 @@
// BackgroundRefreshManager.swift
// Jarvis background story cache refresh. Fires via BGAppRefreshTask every ~30 min,
// fetches the latest feed, and upserts CachedStory rows so the SwiftData cache is
// warm before the user opens the app.
import Foundation
import BackgroundTasks
import SwiftData
import UserNotifications
let jarvisFeedRefreshID = "com.kisani.jarvis.feed.refresh"
enum BackgroundRefreshManager {
// Injected once from JarvisApp so the BG task can open a context on the same store.
static var container: ModelContainer?
// MARK: - Task handler
static func handleFeedRefresh(task: BGAppRefreshTask) {
// Reschedule first if the task is killed we still get another run later.
scheduleFeedRefresh()
guard let container else {
task.setTaskCompleted(success: false)
return
}
let work = Task {
await fetchAndCache(container: container)
}
task.expirationHandler = {
work.cancel()
task.setTaskCompleted(success: false)
}
Task {
_ = await work.result
task.setTaskCompleted(success: !work.isCancelled)
}
}
// MARK: - Core fetch + upsert
private static func fetchAndCache(container: ModelContainer) async {
do {
let page = try await APIClient.shared.fetchStories(limit: 40)
let context = ModelContext(container)
var newStories: [StorySummary] = []
for story in page.data {
let id = story.id
if let existing = (try? context.fetch(
FetchDescriptor<CachedStory>(predicate: #Predicate { $0.id == id })))?.first {
existing.signalScore = story.signalScore
existing.sourceCount = story.sourceCount
existing.updatedAt = story.updatedAt
} else {
context.insert(CachedStory(from: story))
newStories.append(story)
}
}
try? context.save()
// Pre-cache articles for offline reading
await preCacheArticles(page.data, container: container)
if !newStories.isEmpty {
notifyNewStories(newStories)
}
} catch {
// Network unavailable in background fail silently, next run will catch up.
}
}
// MARK: - Article pre-caching
/// Fetch and store the top article for each high-signal uncached story so
/// the user can read them offline without having opened them first.
/// Safe to call in background or foreground creates its own ModelContext.
static func preCacheArticles(_ stories: [StorySummary], container: ModelContainer) async {
let context = ModelContext(container)
let top = stories
.filter { $0.signalScore >= 60 && !$0.sources.isEmpty }
.sorted { $0.signalScore > $1.signalScore }
.prefix(20)
for story in top {
let sid = story.id
let alreadyCached = (try? context.fetch(
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.storyId == sid })
))?.isEmpty == false
guard !alreadyCached, let src = story.sources.first else { continue }
guard let art = try? await APIClient.shared.fetchArticle(id: src.id) else { continue }
context.insert(CachedArticle(from: art))
try? context.save()
}
}
// MARK: - New-story notifications
// Slugs for the featured categories the user cares about.
private static let featuredSlugs: Set<String> = [
"artificial-intelligence", "machine-learning", "robotics",
"technology", "cybersecurity", "security", "privacy-and-data-protection",
"programming-and-software-development", "cloud-computing", "web-design-and-ui-ux",
"homelab",
]
private static let minSignal = 65
private static func notifyNewStories(_ stories: [StorySummary]) {
// Best overall story above threshold.
let eligible = stories
.filter { $0.signalScore >= minSignal }
.sorted { $0.signalScore > $1.signalScore }
guard let lead = eligible.first else { return }
let tags = Set((lead.tags ?? []).isEmpty ? [lead.topic] : (lead.tags ?? []))
let isFeatured = !tags.isDisjoint(with: featuredSlugs)
let content = UNMutableNotificationContent()
content.title = isFeatured
? "📡 \(Topic.label(lead.topic))"
: "📡 New signals"
content.body = lead.headline
if eligible.count > 1 {
content.subtitle = "+\(eligible.count - 1) more new \(isFeatured ? "stories" : "high-signal stories")"
}
content.sound = .default
let req = UNNotificationRequest(
identifier: "feed.new.\(UUID().uuidString)",
content: content,
trigger: nil
)
UNUserNotificationCenter.current().add(req)
}
// MARK: - Schedule
static func scheduleFeedRefresh() {
let req = BGAppRefreshTaskRequest(identifier: jarvisFeedRefreshID)
// iOS enforces a minimum interval (~15 min); 30 min is a reasonable target.
req.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60)
try? BGTaskScheduler.shared.submit(req)
}
}

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 = 72.0 // 3 days: mirrors backend active story window
private static let markerHours = 192.0 // 8 days: read suppression survives weekly repeats
private static let articleDays = 14.0 // cached article bodies (backend article retention)
/// Age-based eviction. Run on launch. Cheap; protects saved stories.

View File

@@ -0,0 +1,334 @@
// StoryStore.swift
// Jarvis central observable store. Views read from here, never hit the API directly.
import Foundation
import Combine
import SwiftData
@MainActor
final class StoryStore: ObservableObject {
static let shared = StoryStore()
@Published var stories: [StorySummary] = []
@Published var isLoading = false
@Published var isLoadingMore = false
@Published var error: APIError?
@Published var lastSyncedAt: Date?
@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.
/// Encoding runs off the main thread; UserDefaults.set is thread-safe.
private func persistQuickCache() {
let payload = QuickCachePayload(stories: Array(stories.prefix(100)), savedAt: Date())
let key = quickCacheKey
Task.detached {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
if let data = try? encoder.encode(payload) {
UserDefaults.standard.set(data, forKey: key)
}
}
}
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
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: 90 * 1_000_000_000)
guard !Task.isCancelled else { return }
await self?.loadFeedFull()
}
}
}
func stopForegroundSync() {
foregroundSyncTask?.cancel()
foregroundSyncTask = nil
}
private let ws = WebSocketManager.shared
private let api = APIClient.shared
private init() {
restoreQuickCache() // synchronous stories are ready before first render
subscribeToWebSocket()
}
// MARK: - Load
@discardableResult
func loadStories(refresh: Bool = false) async -> Int {
guard !isLoading else { return 0 }
isLoading = true
error = nil
let cursor = refresh ? nil : nextCursor
var newCount = 0
do {
let result = try await api.fetchStories(
cursor: cursor,
topic: selectedTopic,
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 {
// 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
print("[StoryStore] fetch failed: \(e.localizedDescription)")
} catch {
print("[StoryStore] unexpected fetch error: \(error)")
}
isLoading = false
return newCount
}
private func preCacheArticlesInBackground() {
guard let container = BackgroundRefreshManager.container else { return }
let snapshot = stories
Task.detached { await BackgroundRefreshManager.preCacheArticles(snapshot, container: container) }
}
/// Merge cached stories into the live feed (used when pull-refresh finds nothing new).
func mergeStories(_ extra: [StorySummary]) async {
let existing = Set(stories.map(\.id))
let novel = extra.filter { !existing.contains($0.id) }
guard !novel.isEmpty else { return }
stories = (stories + novel).sorted(by: StorySummary.feedOrder)
}
func loadMore() async {
guard hasMore, !isLoadingMore, let cursor = nextCursor else { return }
isLoadingMore = true
do {
let result = try await api.fetchStories(cursor: cursor, topic: selectedTopic, tags: selectedTags)
// 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 {
print("[StoryStore] loadMore error: \(error)")
}
isLoadingMore = false
}
/// 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?.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
let sections = supplementSections
Task { [weak self] in
guard let self else { return }
var merged: [String: [StorySummary]] = [:]
await withTaskGroup(of: (String, [StorySummary]?).self) { group in
for sec in sections {
let id = sec.id
let tags = sec.tags
group.addTask {
let result = try? await APIClient.shared.fetchStories(tags: tags, limit: 8)
return (id, result?.data)
}
}
for await (id, data) in group {
if let data { merged[id] = data }
}
}
self.sectionSupplement = merged // single main-thread update
}
}
func setTopic(_ topic: String?) async {
selectedTopic = topic
selectedTags = []
await loadFeedFull()
}
func setPill(_ pill: StoryPill) async {
selectedTopic = nil
selectedTags = pill.slugs
nextCursor = nil
// 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
private func subscribeToWebSocket() {
ws.events
.receive(on: DispatchQueue.main)
.sink { [weak self] event in
self?.handle(event: event)
}
.store(in: &cancellables)
}
private func handle(event: WSEvent) {
switch event.type {
case .storyUpdated:
guard let id = event.storyId,
let score = event.signalScore,
let count = event.sourceCount else { return }
updateStory(id: id, signalScore: score, sourceCount: count)
Task { await refetch(storyId: id) }
case .storyCreated:
scheduleCoalescedRefresh()
case .storyStale:
guard let id = event.storyId else { return }
removeStory(id: id)
case .feedHealth:
NotificationCenter.default.post(name: .feedHealthChanged, object: event)
default:
break
}
}
private func updateStory(id: String, signalScore: Int, sourceCount: Int) {
guard let idx = stories.firstIndex(where: { $0.id == id }) else { return }
let old = stories[idx]
let updated = StorySummary(
id: old.id,
headline: old.headline,
summary: old.summary,
topic: old.topic,
tags: old.tags,
signalScore: signalScore,
scoreBreakdown: old.scoreBreakdown,
sourceCount: sourceCount,
sources: old.sources,
consensus: old.consensus,
conflict: old.conflict,
updatedAt: old.updatedAt,
createdAt: old.createdAt,
firstSeenAt: old.firstSeenAt
)
stories[idx] = updated
stories.sort(by: StorySummary.feedOrder)
}
private func removeStory(id: String) {
stories.removeAll { $0.id == id }
}
private func refetch(storyId: String) async {
// Lightweight: only update what changed via WS patch above.
// Full detail is fetched lazily when user taps into the story.
}
}
extension Notification.Name {
static let feedHealthChanged = Notification.Name("feedHealthChanged")
}

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
@@ -34,7 +34,6 @@ struct ConnectivityView: View {
.padding(.vertical, 12)
}
}
.preferredColorScheme(.dark)
.presentationDragIndicator(.visible)
.task { await recheck() }
}
@@ -45,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")
@@ -85,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"))
@@ -161,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"))
@@ -176,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")
@@ -196,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)
}
}
@@ -232,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))
@@ -255,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 {
@@ -268,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()
}
@@ -288,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()
@@ -313,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")
@@ -77,7 +77,6 @@ struct AddFeedSheet: View {
}
.padding(.horizontal, 20)
}
.preferredColorScheme(.dark)
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
@@ -88,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
@@ -94,7 +94,6 @@ struct FeedManagerView: View {
feedList
}
}
.preferredColorScheme(.dark)
.presentationDragIndicator(.visible)
.sheet(isPresented: $showAdd) {
AddFeedSheet { url, name in
@@ -142,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)
@@ -157,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)
@@ -174,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)
}
@@ -208,7 +207,7 @@ struct FeedManagerView: View {
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.black)
.background(Palette.background)
.refreshable { await vm.load() }
}
@@ -216,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) {
@@ -231,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))
}
}
@@ -239,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)
}
}
@@ -268,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

@@ -5,16 +5,6 @@
import SwiftUI
import SwiftData
// Reusable wordmark: J in KisaniOrange, the rest white, weight 800, kerning -2.
struct JarvisWordmark: View {
var size: CGFloat = 30
var body: some View {
(Text("J").foregroundColor(Palette.orange) + Text("arvis").foregroundColor(.white))
.font(.system(size: size, weight: .heavy))
.kerning(-2)
}
}
struct SignalFeedView: View {
@EnvironmentObject var store: StoryStore
@EnvironmentObject var ws: WebSocketManager
@@ -35,32 +25,42 @@ struct SignalFeedView: View {
/// Reference-type set so marking a story seen on scroll doesn't re-render.
@State private var seenTracker = SeenTracker()
@State private var showRead = false
@State private var markAllToast: String?
@State private var markAllPending = false
/// Stories that have full article content cached eligible for the green dot.
private var cachedStoryIds: Set<String> { Set(cachedArticles.map(\.storyId)) }
private var readStoryIds: Set<String> { Set(readStories.map(\.id)) }
private var savedStoryIds: Set<String> { Set(savedStories.map(\.id)) }
/// Live stories when online; cached fallback when offline. Filtered by the
/// selected pill via backend tags no headline keyword matching.
/// 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 {
base = store.stories.sorted(by: StorySummary.feedOrder)
} else if !ws.connectionState.isLive {
base = cachedStories.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
} else {
base = []
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.
/// The main feed: unread stories, with already-seen ones sunk below fresh
/// ones, and fossils (old by firstSeenAt, see StorySummary.isFossil) sunk
/// below everything a high signalScore alone can't make old news lead.
private var mainStories: [StorySummary] {
let notRead = filteredStories.filter { !readStoryIds.contains($0.id) }
let fresh = notRead.filter { !seenSnapshot.contains($0.id) }
let seen = notRead.filter { seenSnapshot.contains($0.id) }
return fresh + seen
let current = notRead.filter { !$0.isFossil }
let fossils = notRead.filter { $0.isFossil }
let fresh = current.filter { !seenSnapshot.contains($0.id) }
let seen = current.filter { seenSnapshot.contains($0.id) }
return fresh + seen + fossils
}
/// Read stories, tucked into the collapsible "Read" shelf.
@@ -68,22 +68,39 @@ struct SignalFeedView: View {
filteredStories.filter { readStoryIds.contains($0.id) }
}
/// "All" shows a sectioned front page; specific pills show a flat list.
private var isDigest: Bool { selectedPill == .all }
/// 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.
private var digest: (top: [StorySummary], sections: [(NewsSection, [StorySummary])]) {
/// 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))
// Fossils (old by firstSeenAt) sink below current stories so the lead
// slot is never a high-scoring but stale story see StorySummary.isFossil.
let current = unread.filter { !$0.isFossil }
let fossils = unread.filter { $0.isFossil }
let reordered = current + fossils
let top = Array(reordered.prefix(5))
var pool = Array(reordered.dropFirst(5))
var usedIds = Set(top.map(\.id))
var out: [(NewsSection, [StorySummary])] = []
for section in NewsSection.sections {
for section in sections {
var picked: [StorySummary] = [], rest: [StorySummary] = []
for s in pool {
if picked.count < 3 && s.inSection(section) { picked.append(s) }
if picked.count < 4 && s.inSection(section) { picked.append(s) }
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)
@@ -108,22 +125,41 @@ struct SignalFeedView: View {
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
modelContext.delete(row)
try? modelContext.save()
Task { try? await APIClient.shared.markStoryUnread(id: id) }
}
}
var body: some View {
NavigationStack {
ZStack {
Color.black.ignoresSafeArea()
ZStack(alignment: .bottom) {
Palette.background.ignoresSafeArea()
VStack(spacing: 0) {
header
syncBar
topicPills
if !isDigest { columnHeaders } // sections carry their own headers
Divider().overlay(Palette.hairline)
feedList
}
if let toast = markAllToast {
Text(toast)
.font(.system(size: 13, weight: .medium, design: .monospaced))
.foregroundStyle(Color(hex: "E0E0E0"))
.padding(.horizontal, 18)
.padding(.vertical, 11)
.background(Color(hex: "1E1E1E"))
.clipShape(Capsule())
.overlay(Capsule().stroke(Color(hex: "333333"), lineWidth: 0.5))
.shadow(color: .black.opacity(0.4), radius: 12, y: 4)
.padding(.bottom, 24)
.transition(.move(edge: .bottom).combined(with: .opacity))
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) {
withAnimation(.easeOut(duration: 0.25)) { markAllToast = nil }
}
}
}
}
.navigationBarHidden(true)
.navigationDestination(for: StorySummary.self) { story in
@@ -142,49 +178,54 @@ struct SignalFeedView: View {
ShareSheet(items: shareItems(story))
}
}
.preferredColorScheme(.dark)
.onChange(of: store.stories) { _, newValue in
cacheStories(newValue)
}
.task { CacheMaintenance.prune(modelContext) } // bound the caches on launch
.task(id: selectedPill) {
var pages = 0
if selectedPill == .all {
// Front page: pull enough stories to fill the sections.
while store.stories.count < 100 && store.hasMore && pages < 5 {
await store.loadMore(); pages += 1
}
} else {
// Sparse pill: pull a few more pages until something matches.
while filteredStories.isEmpty && store.hasMore && pages < 8 {
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)
}
Menu {
Button {
Task { await markAllRead() }
} label: {
Label(
markAllPending ? "Marking…" : "Mark all as read",
systemImage: markAllPending ? "hourglass" : "checkmark.circle"
)
}
.disabled(markAllPending || mainStories.isEmpty)
Divider()
Button {
showSettings = true
} label: {
Label("Settings", systemImage: "gearshape")
}
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Palette.secondaryText)
.frame(width: 40, height: 40)
}
}
}
.padding(.horizontal, 16)
@@ -193,18 +234,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 {
@@ -218,7 +274,9 @@ struct SignalFeedView: View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(StoryPill.allCases) { p in
pill(p.label, selected: selectedPill == p) { selectedPill = p }
pill(p.label, selected: selectedPill == p) {
selectedPill = p
}
}
}
.padding(.horizontal, 16)
@@ -229,10 +287,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())
}
@@ -249,7 +307,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)
}
@@ -258,61 +316,102 @@ struct SignalFeedView: View {
private var feedList: some View {
List {
if mainStories.isEmpty && readItems.isEmpty {
emptyState.plainBlackRow()
} else if isDigest {
digestContent
} else {
flatContent
}
digestContent
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.black)
.background(Palette.background)
.animation(.snappy, value: showRead)
.onAppear { captureSeenSnapshot() }
.animation(.snappy, value: readStories.count)
.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 {
captureSeenSnapshot() // sink what you've already seen
await store.loadStories(refresh: true)
seenSnapshot = []
seenTracker.ids = []
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)
}
}
}
/// Flat list for a specific pill.
@ViewBuilder private var flatContent: some View {
ForEach(mainStories) { mainRow($0) }
if mainStories.isEmpty { caughtUpRow.plainBlackRow() }
if store.isLoadingMore {
ProgressView().tint(Palette.orange)
.frame(maxWidth: .infinity).padding(.vertical, 20).plainBlackRow()
}
readShelf
}
/// Sectioned front page for "All".
/// 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 d = digest
sectionHeader("TOP STORIES", pill: nil)
if let lead = d.top.first {
mainRow(lead, hero: true)
ForEach(d.top.dropFirst()) { mainRow($0) }
}
ForEach(d.sections, id: \.0.id) { section, stories in
sectionHeader(section.label.uppercased(), pill: section.pill)
ForEach(stories) { mainRow($0) }
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
}
/// 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)
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()
}
}
private func sectionHeader(_ label: String, pill: StoryPill?) -> some View {
HStack(alignment: .firstTextBaseline) {
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 {
Button { selectedPill = pill } label: {
Button {
selectedPill = pill
} label: {
HStack(spacing: 2) {
Text("See all"); Image(systemName: "chevron.right").font(.system(size: 10, weight: .bold))
}
@@ -335,7 +434,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)
@@ -367,33 +466,33 @@ 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)
}
if !story.sources.isEmpty {
SourceChips(names: story.sources.map(\.name), total: story.sourceCount)
}
Text("\(story.sourceCount) sources · \(story.updatedAt.timeAgoShort()) ago")
Text("\(story.sourceCount) sources · \((story.firstSeenAt ?? story.updatedAt).timeAgoShort()) ago")
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(Color(hex: "666666"))
.foregroundStyle(Palette.tertiaryText)
}
.padding(16)
Spacer(minLength: 0)
@@ -403,11 +502,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"))
}
@@ -432,7 +547,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())
}
@@ -464,8 +579,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))
@@ -475,7 +590,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)
@@ -483,14 +598,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"))
@@ -503,7 +618,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")
@@ -514,7 +629,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)
}
@@ -522,13 +637,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)
@@ -562,9 +677,49 @@ struct SignalFeedView: View {
if existing?.first == nil {
modelContext.insert(ReadStory(id: id))
try? modelContext.save()
Task { try? await APIClient.shared.markStoryRead(id: id) }
withAnimation(.snappy) { showRead = true }
}
}
private func markAllRead() async {
let ids = mainStories.map(\.id).filter { !readStoryIds.contains($0) }
guard !ids.isEmpty else {
withAnimation { markAllToast = "No unread \(selectedPill.label) stories right now." }
return
}
markAllPending = true
// Optimistic: insert ReadStory for every ID being marked.
for id in ids {
let exists = (try? modelContext.fetch(
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first
if exists == nil { modelContext.insert(ReadStory(id: id)) }
}
try? modelContext.save()
withAnimation(.snappy) {
showRead = true
markAllToast = "Marked \(ids.count) \(ids.count == 1 ? "story" : "stories") as read."
}
do {
try await APIClient.shared.markAllRead(storyIds: ids, pill: selectedPill.label)
} catch {
// Rollback optimistic state.
for id in ids {
if let row = (try? modelContext.fetch(
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
modelContext.delete(row)
}
}
try? modelContext.save()
withAnimation {
markAllToast = "Sync failed. Stories may reappear until retry."
}
}
markAllPending = false
}
private func shareItems(_ s: StorySummary) -> [Any] {
var items: [Any] = ["\(s.headline)\n\n\(s.summary)\n\nvia Jarvis"]
if let first = s.sources.first, let url = URL(string: first.url) {
@@ -577,22 +732,25 @@ struct SignalFeedView: View {
private func cacheStories(_ stories: [StorySummary]) {
guard !stories.isEmpty else { return }
// One fetch + an in-memory index, instead of a query per story.
let existing = (try? modelContext.fetch(FetchDescriptor<CachedStory>())) ?? []
var byId = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
for summary in stories {
if let found = byId[summary.id] {
found.signalScore = summary.signalScore
found.sourceCount = summary.sourceCount
found.tags = summary.tags ?? []
found.updatedAt = summary.updatedAt
} else {
let c = CachedStory(from: summary)
modelContext.insert(c)
byId[summary.id] = c
let container = modelContext.container
Task.detached {
let ctx = ModelContext(container)
let existing = (try? ctx.fetch(FetchDescriptor<CachedStory>())) ?? []
var byId = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
for summary in stories {
if let found = byId[summary.id] {
found.signalScore = summary.signalScore
found.sourceCount = summary.sourceCount
found.tags = summary.tags ?? []
found.updatedAt = summary.updatedAt
} else {
let c = CachedStory(from: summary)
ctx.insert(c)
byId[summary.id] = c
}
}
try? ctx.save()
}
try? modelContext.save()
}
}
@@ -619,7 +777,8 @@ extension StorySummary {
consensus: cached.consensus,
conflict: cached.conflict,
updatedAt: cached.updatedAt,
createdAt: cached.updatedAt
createdAt: cached.createdAt,
firstSeenAt: cached.firstSeenAt
)
}
}
@@ -628,7 +787,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,65 +34,63 @@ 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())
}
private var fullBody: some View {
HStack(alignment: .top, spacing: 0) {
SignalStripe(score: story.signalScore, muted: isRead)
if isRead {
// No stripe for read stories remove the orange signal indicator entirely.
Color.clear.frame(width: 3)
} else {
SignalStripe(score: story.signalScore)
}
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)
}
.opacity(isRead ? 0.55 : 1)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.black)
.background(Palette.background)
.overlay(alignment: .bottom) {
Rectangle().fill(Palette.hairline).frame(height: 0.5)
}
@@ -101,7 +99,11 @@ struct StoryRowView: View {
private var metaLine: String {
let sources = "\(story.sourceCount) source\(story.sourceCount == 1 ? "" : "s")"
return "\(sources) · \(Topic.label(story.topic)) · \(story.updatedAt.timeAgoShort()) ago"
// firstSeenAt is when the cluster appeared updatedAt bumps on every
// article the backend attaches, so it can read "just now" on stories
// that are actually weeks old.
let age = (story.firstSeenAt ?? story.updatedAt).timeAgoShort()
return "\(sources) · \(Topic.label(story.topic)) · \(age) ago"
}
}
@@ -119,11 +121,11 @@ struct StoryRowView: View {
id: "\(score)", headline: "Bank of Uganda cuts interest rates by 50 basis points",
summary: "", topic: "finance", tags: ["finance"], signalScore: score, scoreBreakdown: breakdown,
sourceCount: 7, sources: sources, consensus: nil, conflict: nil,
updatedAt: Date().addingTimeInterval(-3600), createdAt: Date()),
updatedAt: Date().addingTimeInterval(-3600), createdAt: Date(),
firstSeenAt: Date().addingTimeInterval(-3600)),
isCached: score == 64
)
}
}
.background(Color.black)
.preferredColorScheme(.dark)
.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

@@ -0,0 +1,493 @@
// ArticleReaderView.swift
// Jarvis full article reader. Caches to SwiftData on load; reads from cache offline.
import SwiftUI
import SwiftData
// MARK: - Scroll progress tracking
private struct ContentFrameKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) { value = nextValue() }
}
// MARK: - ViewModel
@MainActor
final class ArticleReaderViewModel: ObservableObject {
@Published var article: Article?
@Published var siblings: [TimelineEntry] = []
@Published var isLoading = false
@Published var error: String?
private let api = APIClient.shared
func load(route: ArticleRoute, context: ModelContext) async {
isLoading = true
error = nil
do {
let art = try await api.fetchArticle(id: route.articleId)
article = art
cache(art, context: context)
if let detail = try? await api.fetchStory(id: route.storyId) {
siblings = detail.timeline
.filter { $0.articleId != route.articleId }
.sorted { $0.publishedAt < $1.publishedAt }
}
} catch {
loadCached(route: route, context: context)
if article == nil {
self.error = (error as? APIError)?.errorDescription ?? error.localizedDescription
}
}
isLoading = false
}
private func cache(_ art: Article, context: ModelContext) {
let id = art.id
let existing = try? context.fetch(
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.id == id }))
if existing?.first == nil {
context.insert(CachedArticle(from: art))
try? context.save()
}
}
private func loadCached(route: ArticleRoute, context: ModelContext) {
let id = route.articleId
if let cached = (try? context.fetch(
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.id == id })))?.first {
article = Article(cached: cached)
}
let sid = route.storyId
let arts = (try? context.fetch(
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.storyId == sid }))) ?? []
siblings = arts
.filter { $0.id != route.articleId }
.map { TimelineEntry(articleId: $0.id, source: $0.source, headline: $0.headline,
publishedAt: $0.publishedAt, isBreaking: false) }
}
}
// MARK: - View
struct ArticleReaderView: View {
let route: ArticleRoute
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@Environment(\.colorScheme) private var scheme
@StateObject private var vm = ArticleReaderViewModel()
@Query private var cachedArticles: [CachedArticle]
@Query private var readStories: [ReadStory]
@Query private var savedStories: [SavedStory]
@AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .dark
@State private var showShare = false
@State private var readProgress: Double = 0
// Adaptive palette
private var bg: Color { scheme == .dark ? Color(hex: "0A0A0A") : Color(hex: "F8F7F4") }
private var bodyFg: Color { scheme == .dark ? Color(hex: "CECECE") : Color(hex: "1A1916") }
private var headFg: Color { scheme == .dark ? Color(hex: "F2F2F2") : Color(hex: "0F0E0C") }
private var mutedFg: Color { scheme == .dark ? Color(hex: "616161") : Color(hex: "888882") }
private var hairline: Color { scheme == .dark ? Color(hex: "1F1F1F") : Color(hex: "E2E0D8") }
private var skelFg: Color { scheme == .dark ? Color(hex: "181818") : Color(hex: "E8E7E3") }
private var isCached: Bool { cachedArticles.contains { $0.id == route.articleId } }
private var isRead: Bool { readStories.contains { $0.id == route.storyId } }
private var isSaved: Bool { savedStories.contains { $0.id == route.storyId } }
private var backTitle: String {
let h = route.parentHeadline
return h.count > 28 ? String(h.prefix(28)).trimmingCharacters(in: .whitespaces) + "" : h
}
var body: some View {
ZStack {
bg.ignoresSafeArea()
VStack(spacing: 0) {
progressBar
scrollContent
}
}
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .topBarLeading) { backButton }
ToolbarItemGroup(placement: .topBarTrailing) { trailingButtons }
}
.toolbarBackground(bg, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
.task { await vm.load(route: route, context: modelContext) }
}
// MARK: - Progress bar
private var progressBar: some View {
ZStack(alignment: .leading) {
Rectangle().fill(hairline).frame(height: 2)
Rectangle()
.fill(Palette.orange)
.frame(width: UIScreen.main.bounds.width * readProgress, height: 2)
.animation(.linear(duration: 0.08), value: readProgress)
}
}
// MARK: - Scroll content
private var scrollContent: some View {
ScrollView {
VStack(spacing: 0) {
if let article = vm.article {
articleBody(article)
} else if vm.isLoading {
skeletonBody
} else {
errorBody
}
}
.padding(.bottom, 56)
.background(
GeometryReader { geo in
Color.clear.preference(
key: ContentFrameKey.self,
value: geo.frame(in: .named("reader"))
)
}
)
}
.coordinateSpace(name: "reader")
.onPreferenceChange(ContentFrameKey.self) { frame in
let viewH = UIScreen.main.bounds.height
let scrollable = frame.height - viewH
guard scrollable > 0 else { readProgress = 0; return }
readProgress = Double(min(1, max(0, -frame.minY / scrollable)))
}
}
// MARK: - Article layout
@ViewBuilder
private func articleBody(_ article: Article) -> some View {
VStack(alignment: .leading, spacing: 0) {
// Source + meta
HStack(spacing: 10) {
Text(article.source)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Palette.primaryText)
.lineLimit(1)
.padding(.horizontal, 10).padding(.vertical, 5)
.background(Palette.orange)
.clipShape(Capsule())
Text(article.publishedAt.clockShort)
.font(.system(size: 12, weight: .regular, design: .monospaced))
.foregroundStyle(mutedFg)
if isCached { cachedBadge }
Spacer()
}
.padding(.horizontal, 22)
.padding(.top, 24)
.padding(.bottom, 20)
// Headline
Text(article.headline)
.font(.system(size: 30, weight: .heavy))
.foregroundStyle(headFg)
.lineSpacing(2)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 22)
// Hero image
heroImage(article.imageUrl)
.padding(.horizontal, 16)
.padding(.top, 22)
// Author
if let author = article.author, !author.isEmpty {
Text(author.hasPrefix("By ") ? author : "By \(author)")
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(mutedFg)
.padding(.horizontal, 22)
.padding(.top, 18)
}
// Section break
Rectangle()
.fill(hairline)
.frame(height: 1)
.padding(.horizontal, 22)
.padding(.top, 26)
.padding(.bottom, 26)
// Body 17pt, generous line-height, readable contrast
Text(article.body)
.font(.system(size: 17, weight: .regular))
.foregroundStyle(bodyFg)
.lineSpacing(8.5)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: 680, alignment: .leading)
.padding(.horizontal, 22)
// Cluster
if !vm.siblings.isEmpty {
Rectangle()
.fill(hairline)
.frame(height: 1)
.padding(.horizontal, 22)
.padding(.top, 44)
.padding(.bottom, 24)
clusterSection
.padding(.horizontal, 22)
}
}
}
// MARK: - Hero image
@ViewBuilder
private func heroImage(_ urlString: String?) -> some View {
if let urlString, let url = URL(string: urlString) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img):
img.resizable()
.aspectRatio(contentMode: .fill)
.transition(.opacity.animation(.easeIn(duration: 0.3)))
case .empty:
PulsingSkeleton(color: skelFg)
case .failure:
RoundedRectangle(cornerRadius: 12)
.fill(skelFg)
.overlay(
Image(systemName: "photo")
.font(.system(size: 24))
.foregroundStyle(mutedFg.opacity(0.5))
)
@unknown default: EmptyView()
}
}
.frame(height: 220)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Cached badge
private var cachedBadge: some View {
HStack(spacing: 4) {
Circle().fill(Color(hex: "4A9E4A")).frame(width: 5, height: 5)
Text("Cached")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(hex: "4A9E4A"))
}
}
// MARK: - Skeleton
private var skeletonBody: some View {
VStack(alignment: .leading, spacing: 0) {
PulsingSkeleton(color: skelFg)
.frame(width: 130, height: 26).clipShape(Capsule())
.padding(.horizontal, 22).padding(.top, 24)
VStack(alignment: .leading, spacing: 10) {
PulsingSkeleton(color: skelFg).frame(height: 30)
.clipShape(RoundedRectangle(cornerRadius: 6))
PulsingSkeleton(color: skelFg).frame(width: 260, height: 30)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.padding(.horizontal, 22).padding(.top, 16)
PulsingSkeleton(color: skelFg)
.frame(height: 220).clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16).padding(.top, 22)
VStack(alignment: .leading, spacing: 10) {
ForEach(0..<5, id: \.self) { i in
PulsingSkeleton(color: skelFg)
.frame(width: i == 4 ? 180 : .infinity, height: 14)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
.padding(.horizontal, 22).padding(.top, 32)
}
}
// MARK: - Error
private var errorBody: some View {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 30))
.foregroundStyle(mutedFg)
Text(vm.error ?? "Article unavailable.")
.font(.system(size: 14))
.foregroundStyle(mutedFg)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.top, 80)
.padding(.horizontal, 40)
}
// MARK: - Cluster section
private var clusterSection: some View {
VStack(alignment: .leading, spacing: 0) {
Text("MORE FROM THIS STORY")
.font(.system(size: 10, weight: .bold, design: .monospaced))
.kerning(0.7)
.foregroundStyle(mutedFg)
.padding(.bottom, 16)
ForEach(vm.siblings.prefix(3)) { entry in
NavigationLink(value: ArticleRoute(
articleId: entry.articleId,
storyId: route.storyId,
parentHeadline: route.parentHeadline
)) {
clusterRow(entry)
}
.buttonStyle(.plain)
}
}
}
private func clusterRow(_ entry: TimelineEntry) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(entry.source)
.font(.system(size: 12, weight: .bold))
.foregroundStyle(Palette.orange)
Spacer()
Text(entry.publishedAt.clockShort)
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(mutedFg)
}
Text(entry.headline)
.font(.system(size: 15, weight: .regular))
.foregroundStyle(bodyFg)
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.leading)
}
.padding(.vertical, 14)
.overlay(alignment: .bottom) {
Rectangle().fill(hairline).frame(height: 0.5)
}
.contentShape(Rectangle())
}
// MARK: - Toolbar
private var backButton: some View {
Button { dismiss() } label: {
HStack(spacing: 4) {
Image(systemName: "chevron.left").font(.system(size: 14, weight: .semibold))
Text(backTitle).font(.system(size: 15, weight: .regular)).lineLimit(1)
}
.foregroundStyle(Palette.orange)
}
}
@ViewBuilder
private var trailingButtons: some View {
Button { showShare = true } label: {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(mutedFg)
}
Button { toggleSave() } label: {
Image(systemName: isSaved ? "bookmark.fill" : "bookmark")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(isSaved ? Palette.orange : mutedFg)
}
Button { toggleRead() } label: {
Image(systemName: isRead ? "checkmark.circle.fill" : "checkmark.circle")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(isRead ? Palette.orange : mutedFg)
}
Menu {
Picker("Appearance", selection: $appearanceMode) {
ForEach(AppearanceMode.allCases, id: \.self) { mode in
Label(mode.label, systemImage: mode.icon).tag(mode)
}
}
.pickerStyle(.inline)
} label: {
Image(systemName: appearanceMode.icon)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(mutedFg)
}
}
// MARK: - Actions
private func toggleRead() {
let id = route.storyId
if let row = (try? modelContext.fetch(
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
modelContext.delete(row)
Task { try? await APIClient.shared.markStoryUnread(id: id) }
} else {
modelContext.insert(ReadStory(id: id))
Task { try? await APIClient.shared.markStoryRead(id: id) }
}
try? modelContext.save()
}
private func toggleSave() {
let id = route.storyId
if let row = (try? modelContext.fetch(
FetchDescriptor<SavedStory>(predicate: #Predicate { $0.id == id })))?.first {
modelContext.delete(row)
} else {
modelContext.insert(SavedStory(id: id))
}
try? modelContext.save()
}
private var shareContent: [Any] {
guard let art = vm.article else { return [] }
var items: [Any] = ["\(art.headline)\n\nvia Jarvis"]
if !art.sourceUrl.isEmpty, let url = URL(string: art.sourceUrl) {
items.append(url)
}
return items
}
}
// MARK: - Pulsing skeleton primitive
private struct PulsingSkeleton: View {
let color: Color
@State private var on = false
var body: some View {
color.opacity(on ? 0.9 : 0.45)
.onAppear {
withAnimation(.easeInOut(duration: 0.85).repeatForever()) { on = true }
}
}
}
// MARK: - Offline Article reconstruction
extension Article {
init(cached: CachedArticle) {
self.init(
id: cached.id,
storyId: cached.storyId,
source: cached.source,
sourceUrl: "",
headline: cached.headline,
body: cached.body,
imageUrl: cached.imageUrl,
author: cached.author,
publishedAt: cached.publishedAt
)
}
}

View File

@@ -28,7 +28,6 @@ struct RootTabView: View {
}
}
.tint(Color("KisaniOrange"))
.preferredColorScheme(.dark)
.task { await store.loadStories(refresh: true) }
}
}

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)
@@ -76,7 +76,6 @@ struct LatestView: View {
.navigationBarHidden(true)
.jarvisDestinations()
}
.preferredColorScheme(.dark)
}
}
@@ -99,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 {
@@ -124,7 +123,6 @@ struct SavedView: View {
.navigationBarHidden(true)
.jarvisDestinations()
}
.preferredColorScheme(.dark)
}
}
@@ -147,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
@@ -165,17 +163,16 @@ struct SearchView: View {
.navigationBarHidden(true)
.jarvisDestinations()
}
.preferredColorScheme(.dark)
}
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)
@@ -189,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
@@ -33,7 +33,6 @@ struct NotificationsView: View {
.padding(.horizontal, 20).padding(.vertical, 12)
}
}
.preferredColorScheme(.dark)
.navigationBarBackButtonHidden(false)
.task { await manager.refreshAuthStatus() }
.onChange(of: settings.morningEnabled) { _, _ in apply() }
@@ -47,13 +46,13 @@ 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)
}
private var permissionCard: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Jarvis needs permission to send briefings and breaking alerts.")
Text("Jervis needs permission to send briefings and breaking alerts.")
.font(.system(size: 14)).foregroundStyle(Color(hex: "BBBBBB"))
.fixedSize(horizontal: false, vertical: true)
Button {
@@ -79,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()
@@ -104,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)
@@ -143,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 {
@@ -181,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))
}
@@ -194,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))
}
@@ -202,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",
@@ -51,7 +56,6 @@ struct SettingsView: View {
}
.navigationBarHidden(true)
}
.preferredColorScheme(.dark)
.presentationDragIndicator(.visible)
.confirmationDialog("Clear offline cache?", isPresented: $showClearConfirm, titleVisibility: .visible) {
Button("Clear cache", role: .destructive) { CacheMaintenance.clear(modelContext) }
@@ -61,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)
@@ -76,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))
@@ -84,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

@@ -0,0 +1,441 @@
// Theme.swift
// Jarvis shared design tokens, signal-score fade math, formatting, nav routes.
// Every hex value here is from the spec; nothing is invented.
import SwiftUI
import UIKit
// MARK: - Hex colors
extension Color {
init(hex: String) {
let s = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
var v: UInt64 = 0
Scanner(string: s).scanHexInt64(&v)
let r, g, b: Double
switch s.count {
case 6:
r = Double((v >> 16) & 0xFF) / 255
g = Double((v >> 8) & 0xFF) / 255
b = Double(v & 0xFF) / 255
default:
r = 0; g = 0; b = 0
}
self = Color(.sRGB, red: r, green: g, blue: b, opacity: 1)
}
}
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 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 = 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 = adaptive(dark: "1F1206", light: "FFE2CA")
static let conflictBorder = adaptive(dark: "5A1A1A", light: "BF5B5B")
static let conflictFill = adaptive(dark: "1A0C0C", light: "F3D8D8")
static let cachedGreen = adaptive(dark: "2A5A2A", light: "2C6A38")
static let bodyText = adaptive(dark: "8A8A8A", light: "4A453F")
}
// MARK: - Signal-score fade
//
// Spec anchors:
// stripe : 97 #FF5C00, fades to #1A1A1A at 0
// title : 97 white, 20 #444444, 019 near invisible
// score : 80100 full orange · 5079 dimmed orange · 2049 dark brown · 019 near invisible
enum Signal {
private static func lerp(_ a: (Double, Double, Double),
_ b: (Double, Double, Double),
_ t: Double) -> Color {
let t = max(0, min(1, t))
return Color(.sRGB,
red: a.0 + (b.0 - a.0) * t,
green: a.1 + (b.1 - a.1) * t,
blue: a.2 + (b.2 - a.2) * t,
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
return lerp((0x1A/255, 0x1A/255, 0x1A/255), // #1A1A1A
(0xFF/255, 0x5C/255, 0x00/255), // #FF5C00
t)
}
/// Story title color: white (97+) #444444 (20) near invisible (0).
static func titleColor(_ score: Int) -> Color {
if score >= 97 { return Palette.primaryText }
if score >= 20 {
let t = Double(score - 20) / Double(97 - 20)
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
)
}
let t = Double(score) / 20.0
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.
/// near-invisible brown (0) full #FF5C00 (100).
static func scoreColor(_ score: Int) -> Color {
let t = Double(score) / 100.0
return lerp((0x2A/255, 0x18/255, 0x10/255), // near-invisible brown
(0xFF/255, 0x5C/255, 0x00/255), // #FF5C00
t)
}
}
// MARK: - Topic display
enum Topic {
static func label(_ slug: String) -> String {
switch slug.lowercased() {
case "ai", "artificial-intelligence": return "AI"
case "us", "united-states": return "US"
case "formula-1": return "Formula 1"
case "ui-ux", "web-design-and-ui-ux": return "Web Design"
default:
// Slug Title Case, e.g. "cloud-computing" "Cloud Computing".
return slug.split(separator: "-")
.map { $0.prefix(1).uppercased() + $0.dropFirst() }
.joined(separator: " ")
}
}
/// Topic pills shown on the home screen.
static let filters: [(label: String, slug: String?)] = [
("All", nil),
("Finance", "finance"),
("Tech", "tech"),
("Politics", "politics"),
("Africa", "africa"),
]
}
// MARK: - Time formatting
extension Date {
/// Short relative string e.g. "just now", "3 min", "2 hr", "4 d".
func timeAgoShort(reference: Date = Date()) -> String {
let secs = max(0, Int(reference.timeIntervalSince(self)))
switch secs {
case 0..<60: return "just now"
case 60..<3600: return "\(secs / 60) min"
case 3600..<86400: return "\(secs / 3600) hr"
default: return "\(secs / 86400) d"
}
}
/// "08:03" style monospaced clock for timeline / source chips.
var clockShort: String {
let f = DateFormatter()
f.dateFormat = "HH:mm"
return f.string(from: self)
}
}
func syncedMinutesAgo(_ date: Date?, reference: Date = Date()) -> String {
guard let date else { return "never" }
return date.timeAgoShort(reference: reference)
}
// MARK: - Navigation routes
/// Route into the article reader. Carries the parent story context so the
/// back button can show the truncated headline.
struct ArticleRoute: Hashable {
let articleId: String
let storyId: String
let parentHeadline: String
}
// MARK: - Appearance mode
enum AppearanceMode: String, CaseIterable {
case system, light, dark
var colorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
var label: String {
switch self {
case .system: return "System"
case .light: return "Light"
case .dark: return "Dark"
}
}
var icon: String {
switch self {
case .system: return "circle.lefthalf.filled"
case .light: return "sun.max"
case .dark: return "moon.stars"
}
}
}
// 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.
//
// Age is measured from `firstSeenAt`, not `updatedAt` the backend bumps
// `updatedAt` every time it attaches another article to a cluster, however
// loosely related (see BACKLOG #15), so a story can read as "just now" while
// being weeks old by `firstSeenAt`. Using `updatedAt` here previously
// neutralized the whole point of this decay: a fossil story's age always
// computed near zero.
extension StorySummary {
var clientRank: Double {
let ageHours = max(0.0, -(firstSeenAt ?? updatedAt).timeIntervalSinceNow) / 3600.0
let decay = 1.0 / (1.0 + ageHours / 24.0)
return Double(signalScore) * (0.80 + 0.20 * decay)
}
/// Even with the fix above, this decay is soft (max 20% penalty) a
/// high enough signalScore can still outrank fresher, lower-scored
/// stories. `isFossil` is a hard backstop views use to keep genuinely
/// old stories out of the lead/hero slot regardless of score.
static let fossilThresholdDays: Double = 5
var isFossil: Bool {
let ageSeconds = -(firstSeenAt ?? updatedAt).timeIntervalSinceNow
return ageSeconds > StorySummary.fossilThresholdDays * 86400
}
static func feedOrder(_ a: StorySummary, _ b: StorySummary) -> Bool {
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
}
}
// MARK: - Story pills (backend-tag driven)
//
// The backend classifies each story into canonical category slugs in `tags[]`.
// The client filters by pill -> slug membership only; no headline keyword
// matching. `topic` is a fallback while the backend finishes emitting `tags[]`.
enum StoryPill: String, CaseIterable, Identifiable {
case all = "All"
case f1 = "F1"
case sport = "Sport"
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"
var id: String { rawValue }
var label: String { rawValue }
var slugs: Set<String> {
switch self {
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 .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
case .eastAfrica: return NewsSection.eastAfricaSubSections
case .southernAfrica: return NewsSection.southernAfricaSubSections
default: return nil
}
}
}
extension StorySummary {
/// Backend `tags[]` are the source of truth; fall back to `topic` during the
/// transition while the backend finishes emitting tags.
var effectiveTags: Set<String> {
let clean = Set((tags ?? []).filter { !$0.isEmpty })
return clean.isEmpty ? [topic] : clean
}
func matches(_ pill: StoryPill) -> Bool {
pill == .all || !effectiveTags.isDisjoint(with: pill.slugs)
}
func inSection(_ section: NewsSection) -> Bool {
!effectiveTags.isDisjoint(with: section.slugs)
}
}
// MARK: - Wordmark
struct JarvisWordmark: View {
var size: CGFloat = 30
private var font: Font { .custom("Courier New", size: size).bold() }
var body: some View {
HStack(spacing: 0) {
Text("j").font(font).foregroundStyle(Palette.orange)
Text("ervis").font(font).foregroundStyle(Palette.primaryText)
}
}
}
// MARK: - News sections (the "All" front page)
//
// Groups the firehose into Apple/Google-News-style sections. A story lands in
// the first section (by this order) whose slugs it carries; `pill` powers "See all".
struct NewsSection: Identifiable {
let id: String
let label: String
let slugs: Set<String>
let pill: StoryPill?
static let sections: [NewsSection] = [
.init(id: "us", label: "US", slugs: ["united-states"], pill: .unitedStates),
.init(id: "world", label: "World", slugs: ["world-news", "news", "news-and-current-affairs"], pill: nil),
.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: "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),
.init(id: "health", label: "Health", slugs: ["health", "health-and-wellness", "mental-health"], pill: nil),
.init(id: "lifestyle", label: "Lifestyle", slugs: ["lifestyle", "food-and-drink", "travel", "gardening", "outdoor-and-hiking", "parenting-and-family", "interior-design-and-home-decor", "photography", "self-improvement-and-personal-development", "education", "e-learning"], pill: nil),
]
/// Sub-sections shown inside the Tech pill view.
static let techSubSections: [NewsSection] = [
.init(id: "t-ai", label: "AI", slugs: ["artificial-intelligence", "machine-learning", "robotics"], pill: nil),
.init(id: "t-cloud", label: "Cloud", slugs: ["cloud-computing"], pill: nil),
.init(id: "t-homelab", label: "HomeLab", slugs: ["homelab"], pill: nil),
.init(id: "t-security", label: "Security", slugs: ["cybersecurity", "security", "privacy-and-data-protection"], pill: nil),
.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

@@ -33,6 +33,9 @@ struct StoryDetailView: View {
@Environment(\.modelContext) private var modelContext
@StateObject private var vm = StoryDetailViewModel()
@Query private var cachedArticles: [CachedArticle]
@Query private var readStories: [ReadStory]
@Query private var savedStories: [SavedStory]
@State private var showShare = false
// Prefer freshly-loaded detail; fall back to the summary we navigated with.
private var topic: String { story.topic }
@@ -54,6 +57,9 @@ struct StoryDetailView: View {
return out
}
private var isRead: Bool { readStories.contains { $0.id == story.id } }
private var isSaved: Bool { savedStories.contains { $0.id == story.id } }
private var cachedIds: Set<String> {
Set(cachedArticles.filter { $0.storyId == story.id }.map(\.id))
}
@@ -63,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)
@@ -99,27 +106,79 @@ struct StoryDetailView: View {
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .topBarLeading) { backButton }
ToolbarItemGroup(placement: .topBarTrailing) {
Button { showShare = true } label: {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Color(hex: "888888"))
}
Button { toggleSave() } label: {
Image(systemName: isSaved ? "bookmark.fill" : "bookmark")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(isSaved ? Palette.orange : Color(hex: "888888"))
}
Button { toggleRead() } label: {
Image(systemName: isRead ? "checkmark.circle.fill" : "checkmark.circle")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(isRead ? Palette.orange : Color(hex: "888888"))
}
}
}
.toolbarBackground(Color.black, for: .navigationBar)
.toolbarBackground(Palette.background, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.preferredColorScheme(.dark)
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
.task {
markRead()
await vm.load(id: story.id)
}
}
// MARK: - Read state
// MARK: - Read / Save / Share
private func markRead() {
let id = story.id
let existing = try? modelContext.fetch(
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })
)
if existing?.first == nil {
guard (try? modelContext.fetch(
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first == nil
else { return }
modelContext.insert(ReadStory(id: id))
try? modelContext.save()
Task { try? await APIClient.shared.markStoryRead(id: id) }
}
private func toggleRead() {
let id = story.id
if let row = (try? modelContext.fetch(
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
modelContext.delete(row)
Task { try? await APIClient.shared.markStoryUnread(id: id) }
} else {
modelContext.insert(ReadStory(id: id))
try? modelContext.save()
Task { try? await APIClient.shared.markStoryRead(id: id) }
}
try? modelContext.save()
}
private func toggleSave() {
let id = story.id
if let row = (try? modelContext.fetch(
FetchDescriptor<SavedStory>(predicate: #Predicate { $0.id == id })))?.first {
modelContext.delete(row)
} else {
// Ensure a CachedStory exists so SavedView can display it.
let alreadyCached = (try? modelContext.fetch(
FetchDescriptor<CachedStory>(predicate: #Predicate { $0.id == id })))?.first != nil
if !alreadyCached { modelContext.insert(CachedStory(from: story)) }
modelContext.insert(SavedStory(id: id))
}
try? modelContext.save()
}
private var shareContent: [Any] {
var items: [Any] = ["\(headline)\n\n\(summary)\n\nvia Jarvis"]
if let first = story.sources.first, let url = URL(string: first.url) {
items.append(url)
}
return items
}
// MARK: - Pieces
@@ -140,7 +199,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))
@@ -173,8 +232,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)
}
@@ -190,7 +249,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 {
@@ -198,7 +257,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,
@@ -220,11 +279,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)
@@ -232,12 +291,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())
@@ -245,12 +304,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

@@ -1,4 +1,4 @@
# Jarvis (iOS)
# Jervis (iOS)
A SwiftUI iOS client for a self-hosted RSS **news-correlation** platform. It shows
stories ranked by a server-computed **signal score**, caches full articles for offline
@@ -18,7 +18,7 @@ reading, and receives live updates over WebSocket.
directly.
```
Jarvis/
Jervis/
Models/ Codable + SwiftData models
Networking/ APIClient (REST), WebSocketManager
Store/ StoryStore, ServerSettings
@@ -37,10 +37,10 @@ Jarvis/
```bash
brew install xcodegen # once
xcodegen generate # produces Jarvis.xcodeproj
open Jarvis.xcodeproj # ⌘R in Xcode, or:
xcodegen generate # produces Jervis.xcodeproj
open Jervis.xcodeproj # ⌘R in Xcode, or:
xcodebuild -project Jarvis.xcodeproj -scheme Jarvis \
xcodebuild -project Jervis.xcodeproj -scheme Jervis \
-sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build
```

View File

@@ -28,6 +28,42 @@ Status: 🔴 open · 🟡 mitigated client-side (real fix still pending) · 🟢
3. 🔴 **[backend] Add accurate per-story tags (topic + region).** So the client can read
tags (sport, uganda, south-africa, …) instead of keyword-guessing. Unblocks #6.
4. 🔴 **[backend] Topic-classifier coverage for the 47 feeds.** science/world/tech mapping.
15. 🔴 **[backend] Freshness score decays from `lastUpdatedAt` (cluster last touched), not
from the actual news event age — old stories never leave the top of the feed.**
Confirmed live on `/api/v1/stories`: 13 of the top 20 stories are 5+ days old by
`firstSeenAt` but still score 90+; the #1 story (`story_511c67eb064d`) has
`firstSeenAt: 2026-06-17`, `lastUpdatedAt: 2026-07-09` (21 days later), `freshnessScore:
1.0`, signal 100. Compounds with #2 (over-clustering): a loose cluster keeps absorbing
tangential articles indefinitely (this story's timeline includes "Security forces ready
for June 30 marches: Ramaphosa" — unrelated to the football story it's attached to),
each attachment resets `lastUpdatedAt` to now, so the cluster's clock never runs out and
its canonical headline drifts to whatever was clustered last (list view showed "South
Africa stun South Korea…"; story detail showed "Updated FIFA rankings after Bafana
Bafana exit" for the *same* `story_id`). **Fix: decay freshness from `firstSeenAt` (or
the newest article whose relevance to the cluster core is verified), not from
"last touched."** Tightening the TF-IDF threshold in #2 should also reduce how often
unrelated articles keep a stale cluster's clock alive. Also confirmed the API's own
`isStale` field is unusable as a stopgap — it reads `false` on every story checked
regardless of actual age, so it isn't computed from a real staleness threshold.
*Client mitigations shipped:*
1. Age display (row meta line, hero card) now reads from `firstSeenAt` instead of
`updatedAt`, so the UI no longer shows "1 hr ago" on stories that are actually
weeks old.
2. Found that `StorySummary.clientRank` — an *existing* time-decay mechanism, already
in the codebase specifically to stop stale high-scorers from leading the feed — was
itself silently neutralized by this same bug: it computed age from `updatedAt`, so a
fossil's age always read as ~0. Fixed to use `firstSeenAt`. This isn't new
client-side ranking logic; it's un-breaking a pre-existing, intentional one.
3. Added `StorySummary.isFossil` (`firstSeenAt` > 5 days old) as a hard backstop —
`clientRank`'s decay is soft (max 20% score penalty) and isn't always enough to
overcome a high `signalScore` alone. Fossils are sunk below current stories in
`mainStories` and `makeDigest`, so a stale story can never occupy the hero/lead slot
even if the server still ranks it #1. They still appear in the feed, just not in
the most prominent, most misleading position.
*Still wrong at the source* — the server's own `signalScore`/`freshnessScore` ordering
(what page 2+, pagination cutoffs, and `min_signal` filtering all rely on) is
unaffected; these are display/ordering patches downstream of it. The real fix is
still backend-side.
## Backend — features

View File

@@ -1,4 +1,4 @@
name: Jarvis
name: Jervis
options:
bundleIdPrefix: com.kisani
deploymentTarget:
@@ -6,22 +6,23 @@ options:
createIntermediateGroups: true
targets:
Jarvis:
Jervis:
type: application
platform: iOS
deploymentTarget: "17.0"
sources:
- path: Jarvis
- path: Jervis
info:
path: Jarvis/Info.plist
path: Jervis/Info.plist
properties:
CFBundleDisplayName: Jarvis
CFBundleDisplayName: Jervis
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
UILaunchScreen:
UIColorName: ""
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
UIUserInterfaceStyle: Dark
NSLocalNetworkUsageDescription: "Jarvis connects to your self-hosted news platform on the local network."
NSLocalNetworkUsageDescription: "Jervis connects to your self-hosted news platform on the local network."
NSAppTransportSecurity:
NSAllowsArbitraryLoads: true
NSAllowsLocalNetworking: true
@@ -34,11 +35,13 @@ targets:
- processing
BGTaskSchedulerPermittedIdentifiers:
- com.kisani.jarvis.briefing.refresh
- com.kisani.jarvis.feed.refresh
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.kisani.jarvis
PRODUCT_NAME: Jervis
MARKETING_VERSION: "1.0"
CURRENT_PROJECT_VERSION: "1"
CURRENT_PROJECT_VERSION: "2"
TARGETED_DEVICE_FAMILY: "1"
SWIFT_VERSION: "5.0"
# Device builds: automatic signing. Change DEVELOPMENT_TEAM to your team