feat: iOS Reader tab — full RSS reader with article reading pane
All checks were successful
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 3s
Security Checks / dependency-audit (push) Successful in 13s

New third tab in the iOS app with:
- ReaderModels matching Reader API response shapes
- ReaderAPI with all endpoints (entries, feeds, categories, counters)
- ReaderViewModel with filters (Unread/Starred/All), pagination, feed management
- ReaderTabView with sub-tabs and feed filter chips
- EntryListView with infinite scroll, context menus, read/unread state
- ArticleView with WKWebView HTML rendering, star/read toggles, Save to Brain
- ArticleWebView (UIViewRepresentable WKWebView wrapper)
- FeedManagementSheet with add/delete/refresh feeds, categories
- Warm Atelier design consistent with existing app

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 16:45:29 -05:00
parent 8892124b8e
commit 426adb3442
10 changed files with 1401 additions and 0 deletions

View File

@@ -40,6 +40,14 @@
A10031 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10031; }; A10031 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10031; };
A10032 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C10001; }; A10032 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C10001; };
A10033 /* FeedbackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10034; }; A10033 /* FeedbackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10034; };
A10040 /* ReaderModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10040; };
A10041 /* ReaderAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10041; };
A10042 /* ReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10042; };
A10043 /* ReaderTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10043; };
A10044 /* EntryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10044; };
A10045 /* ArticleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10045; };
A10046 /* ArticleWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10046; };
A10047 /* FeedManagementSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10047; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@@ -76,6 +84,14 @@
B10031 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; }; B10031 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
B10033 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; B10033 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
B10034 /* FeedbackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackView.swift; sourceTree = "<group>"; }; B10034 /* FeedbackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackView.swift; sourceTree = "<group>"; };
B10040 /* ReaderModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderModels.swift; sourceTree = "<group>"; };
B10041 /* ReaderAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderAPI.swift; sourceTree = "<group>"; };
B10042 /* ReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderViewModel.swift; sourceTree = "<group>"; };
B10043 /* ReaderTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTabView.swift; sourceTree = "<group>"; };
B10044 /* EntryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryListView.swift; sourceTree = "<group>"; };
B10045 /* ArticleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleView.swift; sourceTree = "<group>"; };
B10046 /* ArticleWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleWebView.swift; sourceTree = "<group>"; };
B10047 /* FeedManagementSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedManagementSheet.swift; sourceTree = "<group>"; };
C10001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; C10001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
D10001 /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; }; D10001 /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -131,6 +147,7 @@
F10007 /* Fitness */, F10007 /* Fitness */,
F10014 /* Assistant */, F10014 /* Assistant */,
F10021 /* Feedback */, F10021 /* Feedback */,
F10030 /* Reader */,
); );
path = Features; path = Features;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -268,6 +285,53 @@
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
F10030 /* Reader */ = {
isa = PBXGroup;
children = (
F10031 /* Models */,
F10032 /* API */,
F10033 /* ViewModels */,
F10034 /* Views */,
);
path = Reader;
sourceTree = "<group>";
};
F10031 /* Models */ = {
isa = PBXGroup;
children = (
B10040 /* ReaderModels.swift */,
);
path = Models;
sourceTree = "<group>";
};
F10032 /* API */ = {
isa = PBXGroup;
children = (
B10041 /* ReaderAPI.swift */,
);
path = API;
sourceTree = "<group>";
};
F10033 /* ViewModels */ = {
isa = PBXGroup;
children = (
B10042 /* ReaderViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
};
F10034 /* Views */ = {
isa = PBXGroup;
children = (
B10043 /* ReaderTabView.swift */,
B10044 /* EntryListView.swift */,
B10045 /* ArticleView.swift */,
B10046 /* ArticleWebView.swift */,
B10047 /* FeedManagementSheet.swift */,
);
path = Views;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -369,6 +433,14 @@
A10030 /* Color+Extensions.swift in Sources */, A10030 /* Color+Extensions.swift in Sources */,
A10031 /* Date+Extensions.swift in Sources */, A10031 /* Date+Extensions.swift in Sources */,
A10033 /* FeedbackView.swift in Sources */, A10033 /* FeedbackView.swift in Sources */,
A10040 /* ReaderModels.swift in Sources */,
A10041 /* ReaderAPI.swift in Sources */,
A10042 /* ReaderViewModel.swift in Sources */,
A10043 /* ReaderTabView.swift in Sources */,
A10044 /* EntryListView.swift in Sources */,
A10045 /* ArticleView.swift in Sources */,
A10046 /* ArticleWebView.swift in Sources */,
A10047 /* FeedManagementSheet.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@@ -37,6 +37,10 @@ struct MainTabView: View {
FitnessTabView() FitnessTabView()
.tabItem { Label("Fitness", systemImage: "flame.fill") } .tabItem { Label("Fitness", systemImage: "flame.fill") }
.tag(1) .tag(1)
ReaderTabView()
.tabItem { Label("Reader", systemImage: "newspaper.fill") }
.tag(2)
} }
.tint(Color.accentWarm) .tint(Color.accentWarm)

View File

@@ -0,0 +1,99 @@
import Foundation
struct ReaderAPI {
private let api = APIClient.shared
private let basePath = "/api/reader"
// MARK: - Entries
func getEntries(
status: String? = nil,
starred: Bool? = nil,
feedId: Int? = nil,
categoryId: Int? = nil,
limit: Int = 50,
offset: Int = 0
) async throws -> ReaderEntryList {
var items: [URLQueryItem] = [
URLQueryItem(name: "limit", value: String(limit)),
URLQueryItem(name: "offset", value: String(offset)),
URLQueryItem(name: "direction", value: "desc"),
URLQueryItem(name: "order", value: "published_at"),
]
if let status { items.append(URLQueryItem(name: "status", value: status)) }
if let starred { items.append(URLQueryItem(name: "starred", value: String(starred))) }
if let feedId { items.append(URLQueryItem(name: "feed_id", value: String(feedId))) }
if let categoryId { items.append(URLQueryItem(name: "category_id", value: String(categoryId))) }
return try await api.get("\(basePath)/entries", queryItems: items)
}
func getEntry(id: Int) async throws -> ReaderEntry {
try await api.get("\(basePath)/entries/\(id)")
}
func markEntries(ids: [Int], status: String) async throws {
let body = ReaderBulkUpdate(entryIds: ids, status: status)
try await api.putVoid("\(basePath)/entries", body: body)
}
func markAllRead(feedId: Int? = nil, categoryId: Int? = nil) async throws -> ReaderMarkAllReadResponse {
let body = ReaderMarkAllRead(feedId: feedId, categoryId: categoryId)
return try await api.put("\(basePath)/entries/mark-all-read", body: body)
}
func toggleBookmark(entryId: Int) async throws -> ReaderBookmarkResponse {
// PUT with empty body
return try await api.put("\(basePath)/entries/\(entryId)/bookmark", body: EmptyBody())
}
func fetchFullContent(entryId: Int) async throws -> ReaderEntry {
return try await api.post("\(basePath)/entries/\(entryId)/fetch-full-content", body: EmptyBody())
}
// MARK: - Feeds
func getFeeds() async throws -> [ReaderFeed] {
try await api.get("\(basePath)/feeds")
}
func getCounters() async throws -> ReaderCounters {
try await api.get("\(basePath)/feeds/counters")
}
func createFeed(url: String, categoryId: Int? = nil) async throws -> ReaderFeed {
let body = ReaderFeedCreate(feedUrl: url, categoryId: categoryId)
return try await api.post("\(basePath)/feeds", body: body)
}
func deleteFeed(id: Int) async throws {
_ = try await api.rawRequest("DELETE", path: "\(basePath)/feeds/\(id)")
}
func refreshFeed(id: Int) async throws -> ReaderRefreshResponse {
try await api.post("\(basePath)/feeds/\(id)/refresh", body: EmptyBody())
}
func refreshAllFeeds() async throws -> ReaderRefreshResponse {
try await api.post("\(basePath)/feeds/refresh-all", body: EmptyBody())
}
// MARK: - Categories
func getCategories() async throws -> [ReaderCategory] {
try await api.get("\(basePath)/categories")
}
func createCategory(title: String) async throws -> ReaderCategory {
try await api.post("\(basePath)/categories", body: ReaderCategoryCreate(title: title))
}
// MARK: - Save to Brain
func saveToBrain(url: String) async throws {
let body = ["url": url]
_ = try await api.rawRequest("POST", path: "/api/brain/items", body: body)
}
}
private struct EmptyBody: Codable {}

View File

@@ -0,0 +1,144 @@
import Foundation
// MARK: - Entry
struct ReaderFeedRef: Codable, Hashable {
let id: Int
let title: String
}
struct ReaderEntry: Codable, Identifiable, Hashable {
let id: Int
let title: String?
let url: String?
let content: String?
let fullContent: String?
let author: String?
let publishedAt: String?
let status: String
let starred: Bool
let readingTime: Int
let feed: ReaderFeedRef?
var isRead: Bool { status == "read" }
var displayTitle: String {
title ?? "(Untitled)"
}
var feedName: String {
feed?.title ?? ""
}
var timeAgo: String {
guard let publishedAt, let date = Self.parseDate(publishedAt) else { return "" }
let interval = Date().timeIntervalSince(date)
if interval < 60 { return "now" }
if interval < 3600 { return "\(Int(interval / 60))m" }
if interval < 86400 { return "\(Int(interval / 3600))h" }
if interval < 604800 { return "\(Int(interval / 86400))d" }
return "\(Int(interval / 604800))w"
}
var readingTimeText: String {
"\(readingTime) min"
}
var articleHTML: String {
fullContent ?? content ?? ""
}
private static let isoFormatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private static let isoFormatterNoFrac: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
static func parseDate(_ str: String) -> Date? {
isoFormatter.date(from: str) ?? isoFormatterNoFrac.date(from: str)
}
}
struct ReaderEntryList: Codable {
let total: Int
let entries: [ReaderEntry]
}
// MARK: - Feed
struct ReaderCategoryRef: Codable, Hashable {
let id: Int
let title: String
}
struct ReaderFeed: Codable, Identifiable, Hashable {
let id: Int
let title: String
let feedUrl: String
let siteUrl: String?
let category: ReaderCategoryRef?
}
// MARK: - Category
struct ReaderCategory: Codable, Identifiable, Hashable {
let id: Int
let title: String
}
// MARK: - Counters
struct ReaderCounters: Codable {
let unreads: [String: Int]
func count(forFeed feedId: Int) -> Int {
unreads[String(feedId)] ?? 0
}
var totalUnread: Int {
unreads.values.reduce(0, +)
}
}
// MARK: - Request Bodies
struct ReaderBulkUpdate: Codable {
let entryIds: [Int]
let status: String
}
struct ReaderMarkAllRead: Codable {
let feedId: Int?
let categoryId: Int?
}
struct ReaderFeedCreate: Codable {
let feedUrl: String
let categoryId: Int?
}
struct ReaderCategoryCreate: Codable {
let title: String
}
// MARK: - Responses
struct ReaderBookmarkResponse: Codable {
let starred: Bool
}
struct ReaderRefreshResponse: Codable {
let ok: Bool
let message: String?
}
struct ReaderMarkAllReadResponse: Codable {
let ok: Bool
let marked: Int?
}

View File

@@ -0,0 +1,252 @@
import Foundation
@Observable
final class ReaderViewModel {
// MARK: - State
var entries: [ReaderEntry] = []
var feeds: [ReaderFeed] = []
var categories: [ReaderCategory] = []
var counters: ReaderCounters?
var total = 0
var isLoading = false
var isLoadingMore = false
var isRefreshing = false
var error: String?
// MARK: - Filters
enum ReaderFilter: Hashable {
case unread
case starred
case all
case feed(Int)
case category(Int)
}
var currentFilter: ReaderFilter = .unread {
didSet { Task { await loadEntries(reset: true) } }
}
// MARK: - Private
private let api = ReaderAPI()
private var offset = 0
private let pageSize = 30
private var hasMore = true
// MARK: - Load
func loadInitial() async {
guard !isLoading else { return }
isLoading = true
error = nil
do {
async let feedsResult = api.getFeeds()
async let categoriesResult = api.getCategories()
async let countersResult = api.getCounters()
async let entriesResult = fetchEntries(offset: 0)
feeds = try await feedsResult
categories = try await categoriesResult
counters = try await countersResult
let list = try await entriesResult
entries = list.entries
total = list.total
offset = list.entries.count
hasMore = list.entries.count < list.total
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func loadEntries(reset: Bool = false) async {
if reset {
offset = 0
hasMore = true
entries = []
}
guard !isLoading else { return }
isLoading = true
error = nil
do {
let list = try await fetchEntries(offset: 0)
entries = list.entries
total = list.total
offset = list.entries.count
hasMore = list.entries.count < list.total
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func loadMore() async {
guard !isLoadingMore, hasMore else { return }
isLoadingMore = true
do {
let list = try await fetchEntries(offset: offset)
entries.append(contentsOf: list.entries)
total = list.total
offset += list.entries.count
hasMore = offset < list.total
} catch {
self.error = error.localizedDescription
}
isLoadingMore = false
}
func refresh() async {
isRefreshing = true
do {
_ = try await api.refreshAllFeeds()
// Wait briefly for feeds to update
try await Task.sleep(for: .seconds(2))
counters = try await api.getCounters()
await loadEntries(reset: true)
} catch {
self.error = error.localizedDescription
}
isRefreshing = false
}
// MARK: - Entry Actions
func markAsRead(_ entry: ReaderEntry) async {
guard !entry.isRead else { return }
do {
try await api.markEntries(ids: [entry.id], status: "read")
if let idx = entries.firstIndex(where: { $0.id == entry.id }) {
let updated = try await api.getEntry(id: entry.id)
entries[idx] = updated
}
counters = try await api.getCounters()
} catch {}
}
func toggleRead(_ entry: ReaderEntry) async {
let newStatus = entry.isRead ? "unread" : "read"
do {
try await api.markEntries(ids: [entry.id], status: newStatus)
if let idx = entries.firstIndex(where: { $0.id == entry.id }) {
let updated = try await api.getEntry(id: entry.id)
entries[idx] = updated
}
counters = try await api.getCounters()
} catch {}
}
func toggleStar(_ entry: ReaderEntry) async {
do {
let result = try await api.toggleBookmark(entryId: entry.id)
if let idx = entries.firstIndex(where: { $0.id == entry.id }) {
let updated = try await api.getEntry(id: entry.id)
entries[idx] = updated
}
_ = result
} catch {}
}
func markAllRead() async {
do {
switch currentFilter {
case .feed(let feedId):
_ = try await api.markAllRead(feedId: feedId)
case .category(let catId):
_ = try await api.markAllRead(categoryId: catId)
default:
_ = try await api.markAllRead()
}
counters = try await api.getCounters()
await loadEntries(reset: true)
} catch {}
}
func saveToBrain(_ entry: ReaderEntry) async -> Bool {
guard let url = entry.url else { return false }
do {
try await api.saveToBrain(url: url)
return true
} catch {
return false
}
}
// MARK: - Feed Management
func addFeed(url: String, categoryId: Int? = nil) async throws {
let feed = try await api.createFeed(url: url, categoryId: categoryId)
feeds.append(feed)
feeds.sort { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
}
func deleteFeed(id: Int) async throws {
try await api.deleteFeed(id: id)
feeds.removeAll { $0.id == id }
entries.removeAll { $0.feed?.id == id }
}
func refreshFeed(id: Int) async throws {
_ = try await api.refreshFeed(id: id)
try await Task.sleep(for: .seconds(1))
counters = try await api.getCounters()
}
func addCategory(title: String) async throws {
let cat = try await api.createCategory(title: title)
categories.append(cat)
}
// MARK: - Computed
var feedsByCategory: [(ReaderCategory?, [ReaderFeed])] {
var grouped: [Int?: [ReaderFeed]] = [:]
for feed in feeds {
let key = feed.category?.id
grouped[key, default: []].append(feed)
}
var result: [(ReaderCategory?, [ReaderFeed])] = []
// Uncategorized first
if let uncategorized = grouped[nil], !uncategorized.isEmpty {
result.append((nil, uncategorized))
}
for cat in categories {
if let catFeeds = grouped[cat.id], !catFeeds.isEmpty {
result.append((cat, catFeeds))
}
}
return result
}
var filterTitle: String {
switch currentFilter {
case .unread: return "Unread"
case .starred: return "Starred"
case .all: return "All Articles"
case .feed(let id): return feeds.first { $0.id == id }?.title ?? "Feed"
case .category(let id): return categories.first { $0.id == id }?.title ?? "Category"
}
}
// MARK: - Private Helpers
private func fetchEntries(offset: Int) async throws -> ReaderEntryList {
switch currentFilter {
case .unread:
return try await api.getEntries(status: "unread", limit: pageSize, offset: offset)
case .starred:
return try await api.getEntries(starred: true, limit: pageSize, offset: offset)
case .all:
return try await api.getEntries(limit: pageSize, offset: offset)
case .feed(let feedId):
return try await api.getEntries(feedId: feedId, limit: pageSize, offset: offset)
case .category(let catId):
return try await api.getEntries(categoryId: catId, limit: pageSize, offset: offset)
}
}
}

View File

@@ -0,0 +1,250 @@
import SwiftUI
struct ArticleView: View {
let entry: ReaderEntry
@Bindable var vm: ReaderViewModel
@State private var currentEntry: ReaderEntry
@State private var isFetchingFull = false
@State private var savedToBrain = false
init(entry: ReaderEntry, vm: ReaderViewModel) {
self.entry = entry
self.vm = vm
_currentEntry = State(initialValue: entry)
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
// Article header
VStack(alignment: .leading, spacing: 8) {
Text(currentEntry.displayTitle)
.font(.title2.weight(.bold))
.foregroundStyle(Color.textPrimary)
HStack(spacing: 8) {
Text(currentEntry.feedName)
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.accentWarm)
if let author = currentEntry.author, !author.isEmpty {
Text("\u{2022} \(author)")
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
}
}
HStack(spacing: 12) {
if !currentEntry.timeAgo.isEmpty {
Label(currentEntry.timeAgo, systemImage: "clock")
.font(.caption)
.foregroundStyle(Color.textTertiary)
}
Label(currentEntry.readingTimeText, systemImage: "book")
.font(.caption)
.foregroundStyle(Color.textTertiary)
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
Divider()
.padding(.horizontal, 16)
// Article body
if currentEntry.articleHTML.isEmpty {
if isFetchingFull {
HStack {
ProgressView()
.controlSize(.small)
Text("Fetching article...")
.font(.caption)
.foregroundStyle(Color.textTertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
} else {
VStack(spacing: 12) {
Image(systemName: "doc.text")
.font(.system(size: 32))
.foregroundStyle(Color.textTertiary)
Text("No content available")
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
Button("Fetch Full Article") {
Task { await fetchFull() }
}
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.accentWarm)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
} else {
ArticleWebView(html: wrapHTML(currentEntry.articleHTML))
.frame(minHeight: 400)
}
Spacer(minLength: 80)
}
}
.background(Color.canvas)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
// Star toggle
Button {
Task { await toggleStar() }
} label: {
Image(systemName: currentEntry.starred ? "star.fill" : "star")
.foregroundStyle(currentEntry.starred ? .orange : Color.textTertiary)
}
// Read/unread toggle
Button {
Task { await toggleRead() }
} label: {
Image(systemName: currentEntry.isRead ? "envelope.open" : "envelope.badge")
.foregroundStyle(Color.textTertiary)
}
// More actions
Menu {
if currentEntry.url != nil {
Button {
Task { await saveToBrain() }
} label: {
Label(
savedToBrain ? "Saved!" : "Save to Brain",
systemImage: savedToBrain ? "checkmark.circle" : "brain"
)
}
}
if currentEntry.fullContent == nil {
Button {
Task { await fetchFull() }
} label: {
Label("Fetch Full Article", systemImage: "arrow.down.doc")
}
}
if let url = currentEntry.url, let link = URL(string: url) {
ShareLink(item: link) {
Label("Share", systemImage: "square.and.arrow.up")
}
}
} label: {
Image(systemName: "ellipsis.circle")
.foregroundStyle(Color.textTertiary)
}
}
}
.task {
// Auto-mark as read
await vm.markAsRead(entry)
}
}
private func toggleStar() async {
await vm.toggleStar(currentEntry)
if let updated = vm.entries.first(where: { $0.id == currentEntry.id }) {
currentEntry = updated
}
}
private func toggleRead() async {
await vm.toggleRead(currentEntry)
if let updated = vm.entries.first(where: { $0.id == currentEntry.id }) {
currentEntry = updated
}
}
private func fetchFull() async {
isFetchingFull = true
do {
let updated = try await ReaderAPI().fetchFullContent(entryId: currentEntry.id)
currentEntry = updated
if let idx = vm.entries.firstIndex(where: { $0.id == updated.id }) {
vm.entries[idx] = updated
}
} catch {}
isFetchingFull = false
}
private func saveToBrain() async {
let success = await vm.saveToBrain(currentEntry)
if success {
savedToBrain = true
}
}
private func wrapHTML(_ body: String) -> String {
"""
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, system-ui, sans-serif;
font-size: 17px;
line-height: 1.6;
color: #1f1f1f;
padding: 0 16px 40px;
margin: 0;
background: transparent;
-webkit-text-size-adjust: 100%;
}
img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 12px 0;
}
a { color: #8B6914; }
h1, h2, h3, h4 {
line-height: 1.3;
margin-top: 24px;
margin-bottom: 8px;
}
pre, code {
background: #f5f0e8;
border-radius: 6px;
padding: 2px 6px;
font-size: 15px;
overflow-x: auto;
}
pre { padding: 12px; }
pre code { padding: 0; background: none; }
blockquote {
border-left: 3px solid #8B6914;
margin-left: 0;
padding-left: 16px;
color: #666;
}
figure { margin: 16px 0; }
figcaption {
font-size: 14px;
color: #888;
text-align: center;
margin-top: 4px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
}
td, th {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
</style>
</head>
<body>\(body)</body>
</html>
"""
}
}

View File

@@ -0,0 +1,61 @@
import SwiftUI
import WebKit
struct ArticleWebView: UIViewRepresentable {
let html: String
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
let webView = WKWebView(frame: .zero, configuration: config)
webView.isOpaque = false
webView.backgroundColor = .clear
webView.scrollView.isScrollEnabled = false
webView.scrollView.bounces = false
webView.navigationDelegate = context.coordinator
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
webView.loadHTMLString(html, baseURL: nil)
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject, WKNavigationDelegate {
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
// Allow initial HTML load, open external links in Safari
if navigationAction.navigationType == .linkActivated,
let url = navigationAction.request.url {
UIApplication.shared.open(url)
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// Resize to fit content
webView.evaluateJavaScript("document.body.scrollHeight") { result, _ in
if let height = result as? CGFloat {
webView.frame.size.height = height
webView.invalidateIntrinsicContentSize()
}
}
}
}
}
// Make WKWebView report intrinsic content size
extension WKWebView {
override open var intrinsicContentSize: CGSize {
CGSize(width: UIView.noIntrinsicMetric, height: scrollView.contentSize.height)
}
}

View File

@@ -0,0 +1,146 @@
import SwiftUI
struct EntryListView: View {
@Bindable var vm: ReaderViewModel
var body: some View {
if vm.isLoading && vm.entries.isEmpty {
LoadingView()
} else if vm.entries.isEmpty {
EmptyStateView(
icon: "newspaper",
title: "No articles",
subtitle: vm.currentFilter == .starred
? "Star articles to save them here"
: "All caught up!"
)
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(vm.entries) { entry in
NavigationLink(value: entry) {
EntryRowView(entry: entry, vm: vm)
}
.buttonStyle(.plain)
Divider()
.padding(.leading, 16)
}
// Infinite scroll trigger
if vm.isLoadingMore {
ProgressView()
.padding()
} else {
Color.clear
.frame(height: 1)
.onAppear {
Task { await vm.loadMore() }
}
}
Spacer(minLength: 80)
}
}
.refreshable {
await vm.refresh()
}
.navigationDestination(for: ReaderEntry.self) { entry in
ArticleView(entry: entry, vm: vm)
}
}
}
}
// MARK: - Entry Row
struct EntryRowView: View {
let entry: ReaderEntry
let vm: ReaderViewModel
var body: some View {
HStack(alignment: .top, spacing: 12) {
// Unread indicator
Circle()
.fill(entry.isRead ? Color.clear : Color.accentWarm)
.frame(width: 8, height: 8)
.padding(.top, 6)
VStack(alignment: .leading, spacing: 4) {
Text(entry.displayTitle)
.font(.subheadline.weight(entry.isRead ? .regular : .semibold))
.foregroundStyle(entry.isRead ? Color.textSecondary : Color.textPrimary)
.lineLimit(2)
HStack(spacing: 6) {
Text(entry.feedName)
.font(.caption.weight(.medium))
.foregroundStyle(Color.accentWarm)
.lineLimit(1)
if !entry.timeAgo.isEmpty {
Text("\u{2022}")
.font(.caption2)
.foregroundStyle(Color.textTertiary)
Text(entry.timeAgo)
.font(.caption)
.foregroundStyle(Color.textTertiary)
}
Text("\u{2022}")
.font(.caption2)
.foregroundStyle(Color.textTertiary)
Text(entry.readingTimeText)
.font(.caption)
.foregroundStyle(Color.textTertiary)
}
if let author = entry.author, !author.isEmpty {
Text(author)
.font(.caption)
.foregroundStyle(Color.textTertiary)
.lineLimit(1)
}
}
Spacer()
if entry.starred {
Image(systemName: "star.fill")
.font(.caption)
.foregroundStyle(.orange)
.padding(.top, 4)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.canvas)
.contextMenu {
Button {
Task { await vm.toggleRead(entry) }
} label: {
Label(
entry.isRead ? "Mark Unread" : "Mark Read",
systemImage: entry.isRead ? "envelope.badge" : "envelope.open"
)
}
Button {
Task { await vm.toggleStar(entry) }
} label: {
Label(
entry.starred ? "Unstar" : "Star",
systemImage: entry.starred ? "star.slash" : "star"
)
}
if entry.url != nil {
Button {
Task { await vm.saveToBrain(entry) }
} label: {
Label("Save to Brain", systemImage: "brain")
}
}
}
}
}

View File

@@ -0,0 +1,207 @@
import SwiftUI
// MARK: - Add Feed Sheet
struct AddFeedSheet: View {
@Bindable var vm: ReaderViewModel
@Environment(\.dismiss) private var dismiss
@State private var feedURL = ""
@State private var selectedCategoryId: Int?
@State private var isAdding = false
@State private var error: String?
var body: some View {
NavigationStack {
VStack(spacing: 20) {
VStack(alignment: .leading, spacing: 8) {
Text("Feed URL")
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.textSecondary)
TextField("https://example.com/feed.xml", text: $feedURL)
.textFieldStyle(.roundedBorder)
.keyboardType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
if !vm.categories.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Category (optional)")
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.textSecondary)
Picker("Category", selection: $selectedCategoryId) {
Text("None").tag(nil as Int?)
ForEach(vm.categories) { cat in
Text(cat.title).tag(cat.id as Int?)
}
}
.pickerStyle(.menu)
}
}
if let error {
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
Spacer()
}
.padding()
.background(Color.canvas)
.navigationTitle("Add Feed")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button {
addFeed()
} label: {
if isAdding {
ProgressView().controlSize(.small)
} else {
Text("Add")
.font(.headline)
}
}
.disabled(feedURL.trimmingCharacters(in: .whitespaces).isEmpty || isAdding)
}
}
}
}
private func addFeed() {
isAdding = true
error = nil
Task {
do {
try await vm.addFeed(url: feedURL.trimmingCharacters(in: .whitespaces), categoryId: selectedCategoryId)
dismiss()
} catch {
self.error = error.localizedDescription
}
isAdding = false
}
}
}
// MARK: - Feed Management Sheet
struct FeedManagementSheet: View {
@Bindable var vm: ReaderViewModel
@Environment(\.dismiss) private var dismiss
@State private var feedToDelete: ReaderFeed?
@State private var showAddCategory = false
@State private var newCategoryTitle = ""
@State private var refreshingFeedId: Int?
var body: some View {
NavigationStack {
List {
ForEach(vm.feedsByCategory, id: \.0?.id) { category, feeds in
Section(header: Text(category?.title ?? "Uncategorized")) {
ForEach(feeds) { feed in
feedRow(feed)
}
}
}
Section {
Button {
showAddCategory = true
} label: {
Label("New Category", systemImage: "folder.badge.plus")
.foregroundStyle(Color.accentWarm)
}
}
}
.navigationTitle("Manage Feeds")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
.alert("Delete Feed?", isPresented: .init(
get: { feedToDelete != nil },
set: { if !$0 { feedToDelete = nil } }
)) {
Button("Delete", role: .destructive) {
if let feed = feedToDelete {
Task {
try? await vm.deleteFeed(id: feed.id)
}
}
}
Button("Cancel", role: .cancel) {}
} message: {
if let feed = feedToDelete {
Text("This will remove \"\(feed.title)\" and all its articles.")
}
}
.alert("New Category", isPresented: $showAddCategory) {
TextField("Category name", text: $newCategoryTitle)
Button("Create") {
let title = newCategoryTitle.trimmingCharacters(in: .whitespaces)
guard !title.isEmpty else { return }
Task {
try? await vm.addCategory(title: title)
newCategoryTitle = ""
}
}
Button("Cancel", role: .cancel) { newCategoryTitle = "" }
}
}
}
private func feedRow(_ feed: ReaderFeed) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(feed.title)
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.textPrimary)
.lineLimit(1)
Text(feed.feedUrl)
.font(.caption)
.foregroundStyle(Color.textTertiary)
.lineLimit(1)
}
Spacer()
if let count = vm.counters?.count(forFeed: feed.id), count > 0 {
Text("\(count)")
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.accentWarm)
.clipShape(Capsule())
}
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
feedToDelete = feed
} label: {
Image(systemName: "trash")
}
}
.swipeActions(edge: .leading) {
Button {
Task {
refreshingFeedId = feed.id
try? await vm.refreshFeed(id: feed.id)
refreshingFeedId = nil
}
} label: {
Image(systemName: "arrow.clockwise")
}
.tint(Color.accentWarm)
}
}
}

View File

@@ -0,0 +1,166 @@
import SwiftUI
struct ReaderTabView: View {
@State private var vm = ReaderViewModel()
@State private var selectedSubTab = 0
@State private var showFeedSheet = false
@State private var showFeedManagement = false
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Sub-tab selector
HStack(spacing: 0) {
ForEach(Array(subTabs.enumerated()), id: \.offset) { index, tab in
Button {
withAnimation(.easeInOut(duration: 0.2)) {
selectedSubTab = index
switch index {
case 0: vm.currentFilter = .unread
case 1: vm.currentFilter = .starred
case 2: vm.currentFilter = .all
default: break
}
}
} label: {
HStack(spacing: 4) {
Text(tab)
.font(.subheadline.weight(selectedSubTab == index ? .semibold : .regular))
if index == 0, let counters = vm.counters, counters.totalUnread > 0 {
Text("\(counters.totalUnread)")
.font(.caption2.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.accentWarm)
.clipShape(Capsule())
}
}
.foregroundStyle(selectedSubTab == index ? Color.accentWarm : Color.textSecondary)
.padding(.vertical, 10)
.padding(.horizontal, 16)
.background {
if selectedSubTab == index {
Capsule()
.fill(Color.accentWarm.opacity(0.12))
}
}
}
}
}
.padding(.horizontal)
.padding(.top, 8)
// Feed filter bar
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
feedFilterChip("All", isSelected: isAllSelected) {
let tab = selectedSubTab
switch tab {
case 0: vm.currentFilter = .unread
case 1: vm.currentFilter = .starred
case 2: vm.currentFilter = .all
default: vm.currentFilter = .unread
}
}
ForEach(vm.feeds) { feed in
let count = vm.counters?.count(forFeed: feed.id) ?? 0
feedFilterChip(
feed.title,
count: selectedSubTab == 0 ? count : nil,
isSelected: vm.currentFilter == .feed(feed.id)
) {
vm.currentFilter = .feed(feed.id)
}
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
// Entry list
EntryListView(vm: vm)
}
.background(Color.canvas)
.navigationBarHidden(true)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
Task { await vm.markAllRead() }
} label: {
Label("Mark All Read", systemImage: "checkmark.circle")
}
Button {
Task { await vm.refresh() }
} label: {
Label("Refresh Feeds", systemImage: "arrow.clockwise")
}
Divider()
Button {
showFeedManagement = true
} label: {
Label("Manage Feeds", systemImage: "list.bullet")
}
Button {
showFeedSheet = true
} label: {
Label("Add Feed", systemImage: "plus")
}
} label: {
Image(systemName: "ellipsis.circle")
.foregroundStyle(Color.accentWarm)
}
}
}
.sheet(isPresented: $showFeedSheet) {
AddFeedSheet(vm: vm)
}
.sheet(isPresented: $showFeedManagement) {
FeedManagementSheet(vm: vm)
}
}
.task {
await vm.loadInitial()
}
}
private var subTabs: [String] { ["Unread", "Starred", "All"] }
private var isAllSelected: Bool {
switch vm.currentFilter {
case .unread, .starred, .all: return true
default: return false
}
}
private func feedFilterChip(
_ title: String,
count: Int? = nil,
isSelected: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(spacing: 4) {
Text(title)
.font(.caption.weight(isSelected ? .semibold : .regular))
.lineLimit(1)
if let count, count > 0 {
Text("\(count)")
.font(.system(size: 10).weight(.bold))
}
}
.foregroundStyle(isSelected ? Color.accentWarm : Color.textSecondary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isSelected ? Color.accentWarm.opacity(0.12) : Color.surfaceCard)
.clipShape(Capsule())
}
}
}