rename: Jarvis -> Jervis target, scheme, and folder throughout
The bundle-ID and display-name renames weren't enough — the actual Xcode target/product name was still "Jarvis", which drives CFBundleName, Xcode's Organizer archive list, the .xcodeproj filename, and the scheme name. Renamed all the way through: - project.yml: top-level name, target key, PRODUCT_NAME, source/info paths - Jarvis/ -> Jervis/ (source folder, git-tracked as renames, no content diffs on the moved files) - .gitignore, CI workflows, README, CONTRIBUTING: Jarvis.xcodeproj / scheme Jarvis -> Jervis.xcodeproj / scheme Jervis Verified: xcodebuild -scheme Jervis succeeds, produces Jervis.app, CFBundleName/CFBundleExecutable/CFBundleDisplayName all read "Jervis". CFBundleIdentifier intentionally stays com.kisani.jarvis (per prior decision — bundle ID isn't user-facing anywhere including Organizer). Swift type names (JarvisApp, JarvisWordmark, etc.) and the Gitea repo name/URL are unchanged — pure internal source identifiers, not user or developer-facing product identity. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
320
Jervis/Views/Story/StoryDetailView.swift
Normal file
320
Jervis/Views/Story/StoryDetailView.swift
Normal file
@@ -0,0 +1,320 @@
|
||||
// StoryDetailView.swift
|
||||
// Jarvis — full story: consensus / conflict / coverage timeline.
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
final class StoryDetailViewModel: ObservableObject {
|
||||
@Published var detail: StoryDetail?
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
|
||||
private let api = APIClient.shared
|
||||
|
||||
func load(id: String) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
detail = try await api.fetchStory(id: id)
|
||||
} catch let e as APIError {
|
||||
error = e.errorDescription
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
struct StoryDetailView: View {
|
||||
let story: StorySummary
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@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 }
|
||||
private var score: Int { vm.detail?.signalScore ?? story.signalScore }
|
||||
private var headline: String { vm.detail?.headline ?? story.headline }
|
||||
private var summary: String { vm.detail?.summary ?? story.summary }
|
||||
private var consensus: String? { vm.detail?.consensus ?? story.consensus }
|
||||
private var conflict: String? { vm.detail?.conflict ?? story.conflict }
|
||||
private var sourceCount: Int { vm.detail?.sourceCount ?? story.sourceCount }
|
||||
private var timeline: [TimelineEntry] {
|
||||
(vm.detail?.timeline ?? []).sorted { $0.publishedAt < $1.publishedAt }
|
||||
}
|
||||
|
||||
/// Unique source names in coverage order.
|
||||
private var sourceNames: [String] {
|
||||
var seen = Set<String>(); var out: [String] = []
|
||||
for e in timeline where !seen.contains(e.source) { seen.insert(e.source); out.append(e.source) }
|
||||
if out.isEmpty { out = story.sources.map(\.name) }
|
||||
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))
|
||||
}
|
||||
private var allCached: Bool {
|
||||
!timeline.isEmpty && timeline.allSatisfy { cachedIds.contains($0.articleId) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Palette.background.ignoresSafeArea()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
categoryRow
|
||||
Text(headline)
|
||||
.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: 18, weight: .regular, design: .default))
|
||||
.foregroundStyle(Palette.secondaryText)
|
||||
.lineSpacing(5)
|
||||
}
|
||||
|
||||
SourceChips(names: sourceNames, total: sourceCount)
|
||||
|
||||
if allCached {
|
||||
Text("All \(timeline.count) articles cached · available offline")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(Color(hex: "6FBF6F"))
|
||||
}
|
||||
|
||||
if let consensus { consensusBlock(consensus) }
|
||||
if let conflict { conflictBlock(conflict) }
|
||||
|
||||
timelineSection
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
.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(Palette.background, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
|
||||
.task {
|
||||
markRead()
|
||||
await vm.load(id: story.id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Read / Save / Share
|
||||
|
||||
private func markRead() {
|
||||
let id = story.id
|
||||
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))
|
||||
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
|
||||
|
||||
private var backButton: some View {
|
||||
Button { dismiss() } label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "chevron.left").font(.system(size: 15, weight: .bold))
|
||||
Text("Signal feed").font(.system(size: 16, weight: .regular))
|
||||
}
|
||||
.foregroundStyle(Palette.orange)
|
||||
}
|
||||
}
|
||||
|
||||
private var categoryRow: some View {
|
||||
HStack(spacing: 10) {
|
||||
Text(Topic.label(topic).uppercased())
|
||||
.font(.system(size: 11, weight: .bold, design: .monospaced))
|
||||
.kerning(0.8)
|
||||
.foregroundStyle(Color(hex: "888888"))
|
||||
Text("·").foregroundStyle(Palette.mutedText)
|
||||
Text("\(score) signal")
|
||||
.font(.system(size: 11, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(Signal.scoreColor(score))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 12)
|
||||
.background(Palette.surface)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
private func consensusBlock(_ text: String) -> some View {
|
||||
infoBlock(title: "CONSENSUS", text: text,
|
||||
border: Palette.consensusBorder, fill: Palette.consensusFill,
|
||||
titleColor: Palette.orange)
|
||||
}
|
||||
|
||||
private func conflictBlock(_ text: String) -> some View {
|
||||
infoBlock(title: "CONFLICTING REPORTS", text: text,
|
||||
border: Palette.conflictBorder, fill: Palette.conflictFill,
|
||||
titleColor: Color(hex: "C25555"))
|
||||
}
|
||||
|
||||
private func infoBlock(title: String, text: String, border: Color, fill: Color, titleColor: Color) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
Rectangle().fill(border).frame(width: 3)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
||||
.kerning(0.8)
|
||||
.foregroundStyle(titleColor)
|
||||
Text(text)
|
||||
.font(.system(size: 16, weight: .regular, design: .default))
|
||||
.foregroundStyle(Palette.secondaryText)
|
||||
.lineSpacing(3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(14)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.background(fill)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var timelineSection: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("COVERAGE TIMELINE")
|
||||
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
||||
.kerning(0.8)
|
||||
.foregroundStyle(Palette.tertiaryText)
|
||||
.padding(.bottom, 14)
|
||||
|
||||
if vm.isLoading && timeline.isEmpty {
|
||||
ProgressView().tint(Palette.orange).padding(.vertical, 20)
|
||||
} else if timeline.isEmpty {
|
||||
Text(vm.error ?? "No coverage available.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Palette.tertiaryText)
|
||||
} else {
|
||||
ForEach(Array(timeline.enumerated()), id: \.element.id) { index, entry in
|
||||
NavigationLink(value: ArticleRoute(articleId: entry.articleId,
|
||||
storyId: story.id,
|
||||
parentHeadline: headline)) {
|
||||
timelineRow(entry, isFirst: index == 0,
|
||||
isLast: index == timeline.count - 1,
|
||||
cached: cachedIds.contains(entry.articleId))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private func timelineRow(_ entry: TimelineEntry, isFirst: Bool, isLast: Bool, cached: Bool) -> some View {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
// Node + connector
|
||||
VStack(spacing: 0) {
|
||||
Circle()
|
||||
.fill(isFirst ? Palette.orange : Palette.mutedText)
|
||||
.frame(width: 11, height: 11)
|
||||
.overlay(Circle().stroke(Palette.background, lineWidth: 2))
|
||||
if !isLast {
|
||||
Rectangle().fill(Palette.hairline).frame(width: 1.5)
|
||||
}
|
||||
}
|
||||
.frame(width: 11)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 8) {
|
||||
Text(entry.source)
|
||||
.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(Palette.primaryText)
|
||||
.padding(.horizontal, 5).padding(.vertical, 2)
|
||||
.background(Palette.conflictBorder)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
Spacer()
|
||||
Text(entry.publishedAt.clockShort)
|
||||
.font(.system(size: 11, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(Palette.tertiaryText)
|
||||
if cached { CachedDot(size: 6) }
|
||||
}
|
||||
Text(entry.headline)
|
||||
.font(.system(size: 16, weight: .regular, design: .default))
|
||||
.foregroundStyle(Palette.secondaryText)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.padding(.bottom, isLast ? 0 : 20)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user