feat: iOS Reader tab — full RSS reader with article reading pane
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:
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
99
ios/Platform/Platform/Features/Reader/API/ReaderAPI.swift
Normal file
99
ios/Platform/Platform/Features/Reader/API/ReaderAPI.swift
Normal 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 {}
|
||||||
144
ios/Platform/Platform/Features/Reader/Models/ReaderModels.swift
Normal file
144
ios/Platform/Platform/Features/Reader/Models/ReaderModels.swift
Normal 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?
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
250
ios/Platform/Platform/Features/Reader/Views/ArticleView.swift
Normal file
250
ios/Platform/Platform/Features/Reader/Views/ArticleView.swift
Normal 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>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
146
ios/Platform/Platform/Features/Reader/Views/EntryListView.swift
Normal file
146
ios/Platform/Platform/Features/Reader/Views/EntryListView.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
166
ios/Platform/Platform/Features/Reader/Views/ReaderTabView.swift
Normal file
166
ios/Platform/Platform/Features/Reader/Views/ReaderTabView.swift
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user