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; };
|
||||
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 = "<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>"; };
|
||||
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>"; };
|
||||
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 = "<group>";
|
||||
@@ -268,6 +285,53 @@
|
||||
name = Products;
|
||||
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 */
|
||||
|
||||
/* 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;
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
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