From 426adb344213441b255eede28cef512e9f67cf26 Mon Sep 17 00:00:00 2001 From: Yusuf Suleman Date: Fri, 3 Apr 2026 16:45:29 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20iOS=20Reader=20tab=20=E2=80=94=20full?= =?UTF-8?q?=20RSS=20reader=20with=20article=20reading=20pane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Platform.xcodeproj/project.pbxproj | 72 +++++ ios/Platform/Platform/ContentView.swift | 4 + .../Features/Reader/API/ReaderAPI.swift | 99 +++++++ .../Features/Reader/Models/ReaderModels.swift | 144 ++++++++++ .../Reader/ViewModels/ReaderViewModel.swift | 252 ++++++++++++++++++ .../Features/Reader/Views/ArticleView.swift | 250 +++++++++++++++++ .../Reader/Views/ArticleWebView.swift | 61 +++++ .../Features/Reader/Views/EntryListView.swift | 146 ++++++++++ .../Reader/Views/FeedManagementSheet.swift | 207 ++++++++++++++ .../Features/Reader/Views/ReaderTabView.swift | 166 ++++++++++++ 10 files changed, 1401 insertions(+) create mode 100644 ios/Platform/Platform/Features/Reader/API/ReaderAPI.swift create mode 100644 ios/Platform/Platform/Features/Reader/Models/ReaderModels.swift create mode 100644 ios/Platform/Platform/Features/Reader/ViewModels/ReaderViewModel.swift create mode 100644 ios/Platform/Platform/Features/Reader/Views/ArticleView.swift create mode 100644 ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift create mode 100644 ios/Platform/Platform/Features/Reader/Views/EntryListView.swift create mode 100644 ios/Platform/Platform/Features/Reader/Views/FeedManagementSheet.swift create mode 100644 ios/Platform/Platform/Features/Reader/Views/ReaderTabView.swift diff --git a/ios/Platform/Platform.xcodeproj/project.pbxproj b/ios/Platform/Platform.xcodeproj/project.pbxproj index fcda963..c13717a 100644 --- a/ios/Platform/Platform.xcodeproj/project.pbxproj +++ b/ios/Platform/Platform.xcodeproj/project.pbxproj @@ -40,6 +40,14 @@ A10031 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10031; }; A10032 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C10001; }; 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 */ /* Begin PBXFileReference section */ @@ -76,6 +84,14 @@ B10031 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; B10033 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B10034 /* FeedbackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackView.swift; sourceTree = ""; }; + B10040 /* ReaderModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderModels.swift; sourceTree = ""; }; + B10041 /* ReaderAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderAPI.swift; sourceTree = ""; }; + B10042 /* ReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderViewModel.swift; sourceTree = ""; }; + B10043 /* ReaderTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTabView.swift; sourceTree = ""; }; + B10044 /* EntryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryListView.swift; sourceTree = ""; }; + B10045 /* ArticleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleView.swift; sourceTree = ""; }; + B10046 /* ArticleWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleWebView.swift; sourceTree = ""; }; + B10047 /* FeedManagementSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedManagementSheet.swift; sourceTree = ""; }; C10001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; D10001 /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -131,6 +147,7 @@ F10007 /* Fitness */, F10014 /* Assistant */, F10021 /* Feedback */, + F10030 /* Reader */, ); path = Features; sourceTree = ""; @@ -268,6 +285,53 @@ name = Products; sourceTree = ""; }; + F10030 /* Reader */ = { + isa = PBXGroup; + children = ( + F10031 /* Models */, + F10032 /* API */, + F10033 /* ViewModels */, + F10034 /* Views */, + ); + path = Reader; + sourceTree = ""; + }; + F10031 /* Models */ = { + isa = PBXGroup; + children = ( + B10040 /* ReaderModels.swift */, + ); + path = Models; + sourceTree = ""; + }; + F10032 /* API */ = { + isa = PBXGroup; + children = ( + B10041 /* ReaderAPI.swift */, + ); + path = API; + sourceTree = ""; + }; + F10033 /* ViewModels */ = { + isa = PBXGroup; + children = ( + B10042 /* ReaderViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + F10034 /* Views */ = { + isa = PBXGroup; + children = ( + B10043 /* ReaderTabView.swift */, + B10044 /* EntryListView.swift */, + B10045 /* ArticleView.swift */, + B10046 /* ArticleWebView.swift */, + B10047 /* FeedManagementSheet.swift */, + ); + path = Views; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -369,6 +433,14 @@ A10030 /* Color+Extensions.swift in Sources */, A10031 /* Date+Extensions.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; }; diff --git a/ios/Platform/Platform/ContentView.swift b/ios/Platform/Platform/ContentView.swift index ca992cf..2955b76 100644 --- a/ios/Platform/Platform/ContentView.swift +++ b/ios/Platform/Platform/ContentView.swift @@ -37,6 +37,10 @@ struct MainTabView: View { FitnessTabView() .tabItem { Label("Fitness", systemImage: "flame.fill") } .tag(1) + + ReaderTabView() + .tabItem { Label("Reader", systemImage: "newspaper.fill") } + .tag(2) } .tint(Color.accentWarm) diff --git a/ios/Platform/Platform/Features/Reader/API/ReaderAPI.swift b/ios/Platform/Platform/Features/Reader/API/ReaderAPI.swift new file mode 100644 index 0000000..895dcb0 --- /dev/null +++ b/ios/Platform/Platform/Features/Reader/API/ReaderAPI.swift @@ -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 {} diff --git a/ios/Platform/Platform/Features/Reader/Models/ReaderModels.swift b/ios/Platform/Platform/Features/Reader/Models/ReaderModels.swift new file mode 100644 index 0000000..41d0399 --- /dev/null +++ b/ios/Platform/Platform/Features/Reader/Models/ReaderModels.swift @@ -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? +} diff --git a/ios/Platform/Platform/Features/Reader/ViewModels/ReaderViewModel.swift b/ios/Platform/Platform/Features/Reader/ViewModels/ReaderViewModel.swift new file mode 100644 index 0000000..669935f --- /dev/null +++ b/ios/Platform/Platform/Features/Reader/ViewModels/ReaderViewModel.swift @@ -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) + } + } +} diff --git a/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift b/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift new file mode 100644 index 0000000..13c1890 --- /dev/null +++ b/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift @@ -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 { + """ + + + + + + + \(body) + + """ + } +} diff --git a/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift b/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift new file mode 100644 index 0000000..d8b65f6 --- /dev/null +++ b/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift @@ -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) + } +} diff --git a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift new file mode 100644 index 0000000..4834c85 --- /dev/null +++ b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift @@ -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") + } + } + } + } +} diff --git a/ios/Platform/Platform/Features/Reader/Views/FeedManagementSheet.swift b/ios/Platform/Platform/Features/Reader/Views/FeedManagementSheet.swift new file mode 100644 index 0000000..45fd3ed --- /dev/null +++ b/ios/Platform/Platform/Features/Reader/Views/FeedManagementSheet.swift @@ -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) + } + } +} diff --git a/ios/Platform/Platform/Features/Reader/Views/ReaderTabView.swift b/ios/Platform/Platform/Features/Reader/Views/ReaderTabView.swift new file mode 100644 index 0000000..47f3131 --- /dev/null +++ b/ios/Platform/Platform/Features/Reader/Views/ReaderTabView.swift @@ -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()) + } + } +}