Compare commits

...

27 Commits

Author SHA1 Message Date
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
Robin Kutesa
d192815915 Harden icon pipeline: add ASSETCATALOG_COMPILER_APPICON_NAME to project.yml
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
Previously the setting only existed in the generated pbxproj (where XcodeGen
placed it implicitly). Now it's explicit in project.yml so re-running
`xcodegen generate` always produces a project that picks up the AppIcon set.
Build verified: actool emits AppIcon60x60@2x.png and AppIcon76x76@2x with
correct pixel content from the concentric-rings source icon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 20:36:40 +03:00
Robin Kutesa
c1bf74e3a5 Add Jarvis app icon — concentric orange signal rings
Dark squircle with 16 concentric rings rising from near-black at the
outer edge and center to KisaniOrange (#FF5C00) at the midpoint, with a
Gaussian screen-blend glow pass. Mimics the sonar/radar ring style of
the reference logo using the Jarvis black-and-orange palette.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 20:27:07 +03:00
34 changed files with 1744 additions and 504 deletions

188
CLAUDE.md
View File

@@ -1,53 +1,153 @@
# 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.
---
## 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

View File

@@ -1,6 +1,7 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "JarvisIcon.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,6 +5,7 @@
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.kisani.jarvis.briefing.refresh</string>
<string>com.kisani.jarvis.feed.refresh</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
@@ -53,7 +54,5 @@
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
</dict>
</plist>

View File

@@ -2,13 +2,58 @@
import SwiftUI
import SwiftData
import UIKit
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("JarvisIcon")
.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 {
// Register the briefing background-refresh handler before launch completes.
// Briefing notifications refresh
BGTaskScheduler.shared.register(forTaskWithIdentifier: jarvisBriefingRefreshID, using: nil) { task in
Task { @MainActor in
await NotificationManager.shared.applySettings()
@@ -16,6 +61,13 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
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
}
}
@@ -23,22 +75,40 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
@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 {
Group {
if settings.isConfigured {
RootTabView()
} else {
OnboardingView()
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)
@@ -46,12 +116,35 @@ struct JarvisApp: App {
.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(for: [CachedStory.self, CachedArticle.self, ReadStory.self,
SavedStory.self, SeenStory.self])
.modelContainer(container)
}
}

View File

@@ -136,6 +136,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 +182,9 @@ final class CachedStory {
var consensus: String?
var conflict: String?
var updatedAt: Date
/// Original publish timestamp. Defaults to updatedAt for rows cached before
/// this field was added SwiftData handles the automatic migration.
var createdAt: Date = Date()
var cachedAt: Date
init(from summary: StorySummary) {
@@ -191,6 +198,7 @@ final class CachedStory {
self.consensus = summary.consensus
self.conflict = summary.conflict
self.updatedAt = summary.updatedAt
self.createdAt = summary.createdAt
self.cachedAt = Date()
}
}

View File

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

@@ -41,6 +41,11 @@ final class NotificationManager: NSObject, ObservableObject {
func bootstrap() async {
center.delegate = self
await refreshAuthStatus()
// Prompt for permission on first launch (.notDetermined).
// Re-requesting after denial is a no-op (iOS ignores it) safe to call every launch.
if authStatus == .notDetermined {
await requestAuthorization()
}
subscribeToBreakingEvents()
await applySettings()
}

View File

@@ -51,8 +51,8 @@ final class NotificationSettings: ObservableObject {
weatherEnabled = s.weatherEnabled; locationName = s.locationName
latitude = s.latitude; longitude = s.longitude; resolvedPlace = s.resolvedPlace
} else {
enabled = false
breakingEnabled = true; breakingMinSignal = 80
enabled = true
breakingEnabled = true; breakingMinSignal = 65
morningEnabled = true; morningHour = 7; morningMinute = 0
eveningEnabled = true; eveningHour = 18; eveningMinute = 0
weatherEnabled = true; locationName = ""

View File

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

@@ -16,73 +16,235 @@ final class StoryStore: ObservableObject {
@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.
private func persistQuickCache() {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let payload = QuickCachePayload(stories: Array(stories.prefix(100)), savedAt: Date())
if let data = try? encoder.encode(payload) {
UserDefaults.standard.set(data, forKey: quickCacheKey)
}
}
private struct QuickCachePayload: Codable {
let stories: [StorySummary]
let savedAt: Date
}
// MARK: - SwiftData bootstrap (secondary, async covers first-ever launch before
// the quick cache exists, e.g. app reinstall).
func bootstrap(container: ModelContainer) {
guard stories.isEmpty else { return }
let ctx = ModelContext(container)
if let cached = try? ctx.fetch(FetchDescriptor<CachedStory>()) {
let sorted = cached.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
if !sorted.isEmpty { stories = sorted }
}
}
// MARK: - Foreground sync
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
func loadStories(refresh: Bool = false) async {
guard !isLoading else { return }
@discardableResult
func loadStories(refresh: Bool = false) async -> Int {
guard !isLoading else { return 0 }
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
var newCount = 0
do {
let result = try await api.fetchStories(
cursor: cursor,
topic: selectedTopic
topic: selectedTopic,
tags: selectedTags
)
stories = refresh ? result.data : stories + result.data
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 {
// Benign cancellation (network blip / superseded request): keep state.
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)
stories += result.data
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 {}
} catch {
print("[StoryStore] loadMore error: \(error)")
}
isLoadingMore = false
}
/// Coalesce bursts of `story.created` events into a single refresh so a busy
/// feed (47 sources) doesn't fire a refetch per story.
/// Coalesce bursts of `story.created` WS events waits 3 s then does a
/// full paginated load so stories on page 2+ are captured.
private func scheduleCoalescedRefresh() {
refreshTask?.cancel()
refreshTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 3_000_000_000)
guard !Task.isCancelled else { return }
await self?.loadStories(refresh: true)
await self?.loadFeedFull()
}
}
// MARK: - Section supplements
// Sections with regional or niche tags rarely rank in the global top-100,
// so we fetch a small targeted pool for each sparse section and let
// makeDigest() use it as a fallback.
private let supplementSections: [(id: String, tags: Set<String>)] = [
("east-africa", ["east-africa"]),
("africa", ["africa"]),
("sport", ["sports", "esports", "formula-1"]),
("science", ["science", "astronomy"]),
("health", ["health", "health-and-wellness"]),
]
func loadSectionSupplements() {
guard selectedTags.isEmpty else { return } // only for "All" pill
for sec in supplementSections {
let id = sec.id
let tags = sec.tags
Task { [weak self] in
guard let self else { return }
if let result = try? await api.fetchStories(tags: tags, limit: 8) {
await MainActor.run { self.sectionSupplement[id] = result.data }
}
}
}
}
func setTopic(_ topic: String?) async {
selectedTopic = topic
await loadStories(refresh: true)
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

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,24 +25,30 @@ 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.
@@ -68,22 +64,34 @@ 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))
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 +116,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 +169,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 +225,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 +265,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 +278,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 +298,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 +307,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 +425,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,24 +457,24 @@ struct SignalFeedView: View {
HStack(alignment: .top, spacing: 0) {
SignalStripe(score: story.signalScore, width: 4)
VStack(alignment: .leading, spacing: 11) {
HStack(alignment: .firstTextBaseline) {
HStack(alignment: .center) {
Text(Topic.label(story.topic).uppercased())
.font(.system(size: 10, weight: .bold, design: .monospaced)).kerning(0.8)
.foregroundStyle(Color(hex: "8A8A8A"))
.foregroundStyle(Palette.secondaryText)
Spacer()
Text("\(story.signalScore)")
.font(.system(size: 21, weight: .heavy, design: .monospaced))
.font(.system(size: 15, weight: .semibold, design: .monospaced))
.foregroundStyle(Signal.scoreColor(story.signalScore))
}
Text(story.headline)
.font(.system(size: 24, weight: .heavy))
.foregroundStyle(.white)
.font(.system(size: 25, weight: .bold, design: .default))
.foregroundStyle(Palette.primaryText)
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
if !story.summary.isEmpty {
Text(story.summary)
.font(.system(size: 14))
.foregroundStyle(Color(hex: "9A9A9A"))
.font(.system(size: 16, weight: .regular, design: .default))
.foregroundStyle(Palette.bodyText)
.lineLimit(3).lineSpacing(2)
.fixedSize(horizontal: false, vertical: true)
}
@@ -393,7 +483,7 @@ struct SignalFeedView: View {
}
Text("\(story.sourceCount) sources · \(story.updatedAt.timeAgoShort()) ago")
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(Color(hex: "666666"))
.foregroundStyle(Palette.tertiaryText)
}
.padding(16)
Spacer(minLength: 0)
@@ -403,11 +493,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 +538,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 +570,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 +581,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 +589,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 +609,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 +620,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 +628,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 +668,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) {
@@ -619,7 +765,7 @@ extension StorySummary {
consensus: cached.consensus,
conflict: cached.conflict,
updatedAt: cached.updatedAt,
createdAt: cached.updatedAt
createdAt: cached.createdAt
)
}
}
@@ -628,7 +774,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)
}
@@ -124,6 +122,5 @@ struct StoryRowView: View {
)
}
}
.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

@@ -1,9 +1,18 @@
// ArticleReaderView.swift
// Jarvis full article. Caches to SwiftData on load; reads from cache offline.
// 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?
@@ -20,14 +29,12 @@ final class ArticleReaderViewModel: ObservableObject {
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
@@ -39,8 +46,7 @@ final class ArticleReaderViewModel: ObservableObject {
private func cache(_ art: Article, context: ModelContext) {
let id = art.id
let existing = try? context.fetch(
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.id == id })
)
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.id == id }))
if existing?.first == nil {
context.insert(CachedArticle(from: art))
try? context.save()
@@ -50,14 +56,12 @@ final class ArticleReaderViewModel: ObservableObject {
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 {
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 })
)) ?? []
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,
@@ -65,143 +69,287 @@ final class ArticleReaderViewModel: ObservableObject {
}
}
// 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 article: Article? { vm.article }
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 > 30 ? String(h.prefix(30)).trimmingCharacters(in: .whitespaces) + "" : h
return h.count > 28 ? String(h.prefix(28)).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)
}
bg.ignoresSafeArea()
VStack(spacing: 0) {
progressBar
scrollContent
}
}
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .topBarLeading) { backButton }
ToolbarItemGroup(placement: .topBarTrailing) { trailingButtons }
}
.toolbarBackground(Color.black, for: .navigationBar)
.toolbarBackground(bg, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.preferredColorScheme(.dark)
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
.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)
// 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 content(_ article: Article) -> some View {
VStack(alignment: .leading, spacing: 16) {
// Source pill + timestamp + cached badge
private func articleBody(_ article: Article) -> some View {
VStack(alignment: .leading, spacing: 0) {
// Source + meta
HStack(spacing: 10) {
Text(article.source)
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.white)
.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(Color(hex: "666666"))
if isCached { CachedBadge(text: "Cached") }
.foregroundStyle(mutedFg)
if isCached { cachedBadge }
Spacer()
}
.padding(.horizontal, 22)
.padding(.top, 24)
.padding(.bottom, 20)
// Headline
Text(article.headline)
.font(.system(size: 26, weight: .heavy))
.foregroundStyle(.white)
.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("By \(author)")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(Color(hex: "777777"))
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: 16, weight: .regular))
.foregroundStyle(Palette.bodyText) // #4A4A4A
.lineSpacing(10) // ~1.65 line-height at 16pt
.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 {
Divider().overlay(Palette.hairline).padding(.vertical, 8)
Rectangle()
.fill(hairline)
.frame(height: 1)
.padding(.horizontal, 22)
.padding(.top, 44)
.padding(.bottom, 24)
clusterSection
.padding(.horizontal, 22)
}
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
}
// MARK: - Hero image
@ViewBuilder
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
}
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()
}
} else {
fallback.overlay(Image(systemName: "photo")
.font(.system(size: 26)).foregroundStyle(Color(hex: "3A3A3A")))
}
.frame(height: 220)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.frame(height: 200)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
// 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 CLUSTER")
Text("MORE FROM THIS STORY")
.font(.system(size: 10, weight: .bold, design: .monospaced))
.kerning(0.8)
.foregroundStyle(Color(hex: "555555"))
.padding(.bottom, 12)
.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)) {
NavigationLink(value: ArticleRoute(
articleId: entry.articleId,
storyId: route.storyId,
parentHeadline: route.parentHeadline
)) {
clusterRow(entry)
}
.buttonStyle(.plain)
@@ -210,7 +358,7 @@ struct ArticleReaderView: View {
}
private func clusterRow(_ entry: TimelineEntry) -> some View {
VStack(alignment: .leading, spacing: 5) {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(entry.source)
.font(.system(size: 12, weight: .bold))
@@ -218,21 +366,116 @@ struct ArticleReaderView: View {
Spacer()
Text(entry.publishedAt.clockShort)
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(Color(hex: "555555"))
.foregroundStyle(mutedFg)
}
Text(entry.headline)
.font(.system(size: 15, weight: .regular))
.foregroundStyle(Color(hex: "CFCFCF"))
.foregroundStyle(bodyFg)
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.leading)
}
.padding(.vertical, 12)
.overlay(alignment: .bottom) { Rectangle().fill(Palette.hairline).frame(height: 0.5) }
.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
}
}
// Reconstruct an Article from its cached copy for offline display.
// 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(

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,7 +46,7 @@ struct NotificationsView: View {
// MARK: - Header
private var header: some View {
Text("Notifications").font(.system(size: 22, weight: .heavy)).foregroundStyle(.white)
Text("Notifications").font(.system(size: 22, weight: .heavy)).foregroundStyle(Palette.primaryText)
.padding(.top, 4)
}
@@ -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

@@ -3,6 +3,7 @@
// Every hex value here is from the spec; nothing is invented.
import SwiftUI
import UIKit
// MARK: - Hex colors
@@ -24,28 +25,63 @@ extension Color {
}
}
extension UIColor {
convenience init(hex: String) {
let s = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
var v: UInt64 = 0
Scanner(string: s).scanHexInt64(&v)
let r, g, b: CGFloat
switch s.count {
case 6:
r = CGFloat((v >> 16) & 0xFF) / 255
g = CGFloat((v >> 8) & 0xFF) / 255
b = CGFloat(v & 0xFF) / 255
default:
r = 0; g = 0; b = 0
}
self.init(red: r, green: g, blue: b, alpha: 1)
}
}
// MARK: - Palette
enum Palette {
static let orange = Color("KisaniOrange") // #FF5C00
static let black = Color.black // #000000
static let surface = Color(hex: "111111")
static let surface2 = Color(hex: "1A1A1A")
static let hairline = Color(hex: "1A1A1A")
static let orange = Color("KisaniOrange") // #FF5C00
static func adaptive(dark: String, light: String) -> Color {
Color(UIColor { traits in
traits.userInterfaceStyle == .dark ? UIColor(hex: dark) : UIColor(hex: light)
})
}
static let background = adaptive(dark: "0A0A0A", light: "F7F4EF")
static let black = background
static let surface = adaptive(dark: "111111", light: "EFE9DD")
static let surface2 = adaptive(dark: "1A1A1A", light: "E3DBCE")
static let hairline = adaptive(dark: "1A1A1A", light: "D6CEC0")
static let primaryText = adaptive(dark: "F5F2ED", light: "171411")
static let secondaryText = adaptive(dark: "9A9A9A", light: "4F4941")
static let tertiaryText = adaptive(dark: "666666", light: "6F675E")
static let mutedText = adaptive(dark: "4A4A4A", light: "948B80")
static let chipFill = adaptive(dark: "242424", light: "DDD3C1")
static let chipText = adaptive(dark: "E0E0E0", light: "28231E")
static let chipMutedText = adaptive(dark: "787878", light: "68625A")
// health
static let healthActive = Color(hex: "2A5A2A")
static let healthFailing = Color(hex: "AA6600")
static let healthDead = Color(hex: "5A1A1A")
static let healthActive = adaptive(dark: "2A5A2A", light: "DCEBDD")
static let healthFailing = adaptive(dark: "AA6600", light: "F2D6A6")
static let healthDead = adaptive(dark: "5A1A1A", light: "E9C7C7")
// blocks
static let consensusBorder = Color(hex: "FF5C00")
static let consensusFill = Color(hex: "1F1206") // dark orange
static let conflictBorder = Color(hex: "5A1A1A") // dark red
static let conflictFill = Color(hex: "1A0C0C") // dark red
static let consensusFill = adaptive(dark: "1F1206", light: "FFE2CA")
static let conflictBorder = adaptive(dark: "5A1A1A", light: "BF5B5B")
static let conflictFill = adaptive(dark: "1A0C0C", light: "F3D8D8")
static let cachedGreen = Color(hex: "2A5A2A")
static let bodyText = Color(hex: "4A4A4A")
static let cachedGreen = adaptive(dark: "2A5A2A", light: "2C6A38")
static let bodyText = adaptive(dark: "8A8A8A", light: "4A453F")
}
// MARK: - Signal-score fade
@@ -67,6 +103,30 @@ enum Signal {
opacity: 1)
}
private static func lerpTuple(_ a: (Double, Double, Double),
_ b: (Double, Double, Double),
_ t: Double) -> (Double, Double, Double) {
let t = max(0, min(1, t))
return (
a.0 + (b.0 - a.0) * t,
a.1 + (b.1 - a.1) * t,
a.2 + (b.2 - a.2) * t
)
}
private static func adaptiveLerp(_ darkA: (Double, Double, Double),
_ darkB: (Double, Double, Double),
_ lightA: (Double, Double, Double),
_ lightB: (Double, Double, Double),
_ t: Double) -> Color {
Color(UIColor { traits in
let rgb = traits.userInterfaceStyle == .dark
? lerpTuple(darkA, darkB, t)
: lerpTuple(lightA, lightB, t)
return UIColor(red: rgb.0, green: rgb.1, blue: rgb.2, alpha: 1)
})
}
/// 3pt left stripe color: #1A1A1A (0) #FF5C00 (97+).
static func stripeColor(_ score: Int) -> Color {
let t = Double(score) / 97.0
@@ -77,18 +137,21 @@ enum Signal {
/// Story title color: white (97+) #444444 (20) near invisible (0).
static func titleColor(_ score: Int) -> Color {
if score >= 97 { return .white }
if score >= 97 { return Palette.primaryText }
if score >= 20 {
let t = Double(score - 20) / Double(97 - 20)
return lerp((0x44/255, 0x44/255, 0x44/255), // #444444
(1, 1, 1), // white
t)
return adaptiveLerp(
(0x44/255, 0x44/255, 0x44/255), (0xF5/255, 0xF2/255, 0xED/255),
(0x8A/255, 0x81/255, 0x76/255), (0x17/255, 0x14/255, 0x11/255),
t
)
}
// 019 near invisible: #1C1C1C #444444
let t = Double(score) / 20.0
return lerp((0x1C/255, 0x1C/255, 0x1C/255),
(0x44/255, 0x44/255, 0x44/255),
t)
return adaptiveLerp(
(0x1C/255, 0x1C/255, 0x1C/255), (0x44/255, 0x44/255, 0x44/255),
(0xD6/255, 0xCE/255, 0xC0/255), (0x8A/255, 0x81/255, 0x76/255),
t
)
}
/// Signal score number color: stays in orange/brown hue, fades with score.
@@ -165,14 +228,61 @@ struct ArticleRoute: Hashable {
let parentHeadline: String
}
// MARK: - Stable feed ordering
// 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.
extension StorySummary {
/// Deterministic ranking: signal score descending, then id descending the
/// same order the backend uses (`signal_score DESC, id DESC`). Without the
/// id tiebreaker, equal-score stories reshuffle on every (unstable) re-sort.
var clientRank: Double {
let ageHours = max(0.0, -updatedAt.timeIntervalSinceNow) / 3600.0
let decay = 1.0 / (1.0 + ageHours / 24.0)
return Double(signalScore) * (0.80 + 0.20 * decay)
}
static func feedOrder(_ a: StorySummary, _ b: StorySummary) -> Bool {
a.signalScore != b.signalScore ? a.signalScore > b.signalScore : a.id > b.id
let diff = a.clientRank - b.clientRank
// Stable tiebreak for nearly-equal ranks: newer ID wins (IDs are
// lexicographically monotone from the backend).
if abs(diff) < 0.5 { return a.id > b.id }
return diff > 0
}
}
@@ -186,12 +296,9 @@ 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 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"
@@ -200,19 +307,33 @@ enum StoryPill: String, CaseIterable, Identifiable {
var slugs: Set<String> {
switch self {
case .all: return []
case .f1: return ["formula-1"]
case .sport: return ["sports", "esports"]
case .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"]
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
}
}
}
@@ -234,6 +355,19 @@ extension StorySummary {
}
}
// 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("arvis").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
@@ -246,17 +380,44 @@ struct NewsSection: Identifiable {
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),
.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

@@ -20,7 +20,6 @@ targets:
UIColorName: ""
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
UIUserInterfaceStyle: Dark
NSLocalNetworkUsageDescription: "Jarvis connects to your self-hosted news platform on the local network."
NSAppTransportSecurity:
NSAllowsArbitraryLoads: true
@@ -34,6 +33,7 @@ targets:
- processing
BGTaskSchedulerPermittedIdentifiers:
- com.kisani.jarvis.briefing.refresh
- com.kisani.jarvis.feed.refresh
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.kisani.jarvis
@@ -46,5 +46,6 @@ targets:
# CODE_SIGNING_ALLOWED=NO on the command line and skip signing.
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: "K8BLMMR883"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
ENABLE_USER_SCRIPT_SANDBOXING: "YES"