- Swipe left bookmarks a story (toggle) into the Saved tab; swipe right opens the iOS share sheet and marks the story read. - New SavedStory SwiftData model; Saved tab now lists explicit bookmarks instead of the whole offline cache. - Convert the feed to a styled List (native swipeActions require it), keeping the flat look, pull-to-refresh, and infinite scroll. Add a ShareSheet wrapper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
237 lines
5.2 KiB
Swift
237 lines
5.2 KiB
Swift
// Models.swift
|
|
// Jarvis — all Codable structs matching the API contract
|
|
|
|
import Foundation
|
|
import SwiftData
|
|
|
|
// MARK: - Signal Score
|
|
|
|
struct SignalScore: Codable, Hashable {
|
|
let total: Int
|
|
let sourceAuthority: Int
|
|
let freshness: Int
|
|
let localRelevance: Int
|
|
let crossSourceConfirmation: Int
|
|
let topicImportance: Int
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case total = "signalScore"
|
|
case sourceAuthority, freshness, localRelevance
|
|
case crossSourceConfirmation, topicImportance
|
|
}
|
|
}
|
|
|
|
// MARK: - Source
|
|
|
|
struct StorySource: Codable, Hashable, Identifiable {
|
|
let id: String
|
|
let name: String
|
|
let url: String
|
|
let publishedAt: Date
|
|
let isBreaking: Bool
|
|
}
|
|
|
|
// MARK: - Timeline Entry
|
|
|
|
struct TimelineEntry: Codable, Hashable, Identifiable {
|
|
let articleId: String
|
|
let source: String
|
|
let headline: String
|
|
let publishedAt: Date
|
|
let isBreaking: Bool
|
|
|
|
var id: String { articleId }
|
|
}
|
|
|
|
// MARK: - Story Summary (list view)
|
|
|
|
struct StorySummary: Codable, Identifiable, Hashable {
|
|
let id: String
|
|
let headline: String
|
|
let summary: String
|
|
let topic: String
|
|
let signalScore: Int
|
|
let scoreBreakdown: ScoreBreakdown
|
|
let sourceCount: Int
|
|
let sources: [StorySource]
|
|
let consensus: String?
|
|
let conflict: String?
|
|
let updatedAt: Date
|
|
let createdAt: Date
|
|
}
|
|
|
|
struct ScoreBreakdown: Codable, Hashable {
|
|
let sourceAuthority: Int
|
|
let freshness: Int
|
|
let localRelevance: Int
|
|
let crossSourceConfirmation: Int
|
|
let topicImportance: Int
|
|
}
|
|
|
|
// MARK: - Story Detail (full, with timeline)
|
|
|
|
struct StoryDetail: Codable, Identifiable {
|
|
let id: String
|
|
let headline: String
|
|
let summary: String
|
|
let topic: String
|
|
let signalScore: Int
|
|
let scoreBreakdown: ScoreBreakdown
|
|
let sourceCount: Int
|
|
let consensus: String?
|
|
let conflict: String?
|
|
let timeline: [TimelineEntry]
|
|
let updatedAt: Date
|
|
let createdAt: Date
|
|
}
|
|
|
|
// MARK: - Article (full content, for offline cache)
|
|
|
|
struct Article: Codable, Identifiable {
|
|
let id: String
|
|
let storyId: String
|
|
let source: String
|
|
let sourceUrl: String
|
|
let headline: String
|
|
let body: String
|
|
let imageUrl: String?
|
|
let author: String?
|
|
let publishedAt: Date
|
|
}
|
|
|
|
// MARK: - Feed
|
|
|
|
struct Feed: Codable, Identifiable {
|
|
let id: String
|
|
let name: String
|
|
let url: String
|
|
let health: FeedHealth
|
|
let pollIntervalSeconds: Int
|
|
let failureCount: Int
|
|
let lastFetchedAt: Date?
|
|
let articleCountToday: Int
|
|
}
|
|
|
|
enum FeedHealth: String, Codable {
|
|
case active, failing, dead
|
|
}
|
|
|
|
// MARK: - Server Health
|
|
|
|
struct ServerHealth: Codable {
|
|
let status: String
|
|
let version: String
|
|
let storiesCount: Int
|
|
let feedsCount: Int
|
|
let uptime: Int
|
|
}
|
|
|
|
// MARK: - Paginated Response
|
|
|
|
struct PaginatedStories: Codable {
|
|
let data: [StorySummary]
|
|
let nextCursor: String?
|
|
let hasMore: Bool
|
|
let total: Int
|
|
}
|
|
|
|
// MARK: - WebSocket Events
|
|
|
|
enum WSEventType: String, Codable {
|
|
case storyUpdated = "story.updated"
|
|
case storyCreated = "story.created"
|
|
case storyStale = "story.stale"
|
|
case feedHealth = "feed.health"
|
|
case ping, pong
|
|
}
|
|
|
|
struct WSEvent: Codable {
|
|
let type: WSEventType
|
|
let storyId: String?
|
|
let feedId: String?
|
|
let signalScore: Int?
|
|
let sourceCount: Int?
|
|
let headline: String?
|
|
let topic: String?
|
|
let health: FeedHealth?
|
|
let failureCount: Int?
|
|
let updatedAt: Date?
|
|
let createdAt: Date?
|
|
}
|
|
|
|
// MARK: - SwiftData cached models
|
|
|
|
@Model
|
|
final class CachedStory {
|
|
@Attribute(.unique) var id: String
|
|
var headline: String
|
|
var summary: String
|
|
var topic: String
|
|
var signalScore: Int
|
|
var sourceCount: Int
|
|
var consensus: String?
|
|
var conflict: String?
|
|
var updatedAt: Date
|
|
var cachedAt: Date
|
|
|
|
init(from summary: StorySummary) {
|
|
self.id = summary.id
|
|
self.headline = summary.headline
|
|
self.summary = summary.summary
|
|
self.topic = summary.topic
|
|
self.signalScore = summary.signalScore
|
|
self.sourceCount = summary.sourceCount
|
|
self.consensus = summary.consensus
|
|
self.conflict = summary.conflict
|
|
self.updatedAt = summary.updatedAt
|
|
self.cachedAt = Date()
|
|
}
|
|
}
|
|
|
|
@Model
|
|
final class ReadStory {
|
|
@Attribute(.unique) var id: String
|
|
var readAt: Date
|
|
|
|
init(id: String) {
|
|
self.id = id
|
|
self.readAt = Date()
|
|
}
|
|
}
|
|
|
|
@Model
|
|
final class SavedStory {
|
|
@Attribute(.unique) var id: String
|
|
var savedAt: Date
|
|
|
|
init(id: String) {
|
|
self.id = id
|
|
self.savedAt = Date()
|
|
}
|
|
}
|
|
|
|
@Model
|
|
final class CachedArticle {
|
|
@Attribute(.unique) var id: String
|
|
var storyId: String
|
|
var source: String
|
|
var headline: String
|
|
var body: String
|
|
var imageUrl: String?
|
|
var author: String?
|
|
var publishedAt: Date
|
|
var cachedAt: Date
|
|
|
|
init(from article: Article) {
|
|
self.id = article.id
|
|
self.storyId = article.storyId
|
|
self.source = article.source
|
|
self.headline = article.headline
|
|
self.body = article.body
|
|
self.imageUrl = article.imageUrl
|
|
self.author = article.author
|
|
self.publishedAt = article.publishedAt
|
|
self.cachedAt = Date()
|
|
}
|
|
}
|