Files
ipodderx-app/ios/Sources/FeedListView.swift
Ray Slakinski 01d8bd936e A native feed list and item list (#3)
The lists are native now, and the page is a button in the toolbar: it is
still where signing in happens, and it is still the whole of settings, the
admin page, the directory and OPML, which were never going to be rewritten.

LibraryStore holds a page of items and asks for the next, because the
sorting, filtering and searching are the server's work and ten thousand
items have no business being in memory to be sorted here. Read and pinned
are set locally and sent after, as the page's readWrites map does and for
the same reason: a list asked for before the write lands answers with the
old state, which put the dot back on an item just read.

The interface follows the account's light or dark rather than the phone's,
and defaults to dark when nobody has chosen, because that is what ipx's own
theme.ts does. Following the system instead put a light list in front of a
dark page. preferredColorScheme was not enough on its own -- inside a
hosting controller it did not reach the hierarchy -- so the style is
overridden on the controller, which also carries to the page presented over
it.

The sidebar had to be broken into sub-views: the whole list in one
expression was more than the type checker would work through, and it said
so rather than compiling it.

Tested against the real library, 135 feeds and eleven thousand items, and
the playback test now drives the native row rather than the page's button.
Thirteen pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 09:27:03 -04:00

165 lines
5.9 KiB
Swift

import SwiftUI
/// The sidebar: the places first, then the feeds, with OPML subscriptions as folders.
struct FeedListView: View {
@ObservedObject var store: LibraryStore
@State private var filter = ""
@State private var expanded: Set<String> = []
/// Feeds that are not inside an OPML folder, pinned ones first, then by name.
private var loose: [IPX.Feed] {
store.feeds
.filter { $0.group == nil && !isFolder($0) }
.filter(matches)
.sorted { ($0.pinned ? 0 : 1, $0.name.lowercased()) < ($1.pinned ? 0 : 1, $1.name.lowercased()) }
}
/// An OPML subscription: other feeds name it as their group.
private func isFolder(_ feed: IPX.Feed) -> Bool {
store.feeds.contains { $0.group == feed.id }
}
private var folders: [IPX.Feed] {
store.feeds.filter(isFolder).sorted { $0.name.lowercased() < $1.name.lowercased() }
}
private func children(_ folder: IPX.Feed) -> [IPX.Feed] {
store.feeds.filter { $0.group == folder.id }.filter(matches)
.sorted { $0.name.lowercased() < $1.name.lowercased() }
}
private func matches(_ feed: IPX.Feed) -> Bool {
filter.isEmpty || feed.name.localizedCaseInsensitiveContains(filter)
}
var body: some View {
List(selection: selection) {
placesSection
feedsSection
}
.listStyle(.sidebar)
.scrollContentBackground(.hidden)
.searchable(text: $filter, placement: .sidebar, prompt: "Filter feeds")
.navigationTitle("iPodderX")
}
private var selection: Binding<LibraryStore.Place?> {
Binding(get: { store.place }, set: { store.place = $0 ?? .all })
}
// Split out because the whole list in one expression is more than the type checker will
// work through: it gave up rather than compiling it.
@ViewBuilder private var placesSection: some View {
Section {
let unread = store.feeds.reduce(0) { $0 + $1.unread }
place(.all, "All Subscriptions", "square.3.layers.3d", count: unread)
place(.listening, "Currently Listening", "headphones", count: nil)
}
}
@ViewBuilder private var feedsSection: some View {
Section {
ForEach(loose) { feed in row(feed) }
ForEach(folders) { folder in folderRow(folder) }
}
}
private func folderRow(_ folder: IPX.Feed) -> some View {
DisclosureGroup(isExpanded: expansion(folder.id)) {
ForEach(children(folder)) { row($0) }
} label: {
Label(folder.name, systemImage: "folder").foregroundStyle(Glass.text)
}
}
private func expansion(_ id: String) -> Binding<Bool> {
Binding(get: { expanded.contains(id) },
set: { open in
if open { expanded.insert(id) } else { expanded.remove(id) }
})
}
private func place(_ p: LibraryStore.Place, _ title: String, _ symbol: String, count: Int?) -> some View {
Label(title, systemImage: symbol)
.badge(count.map { $0 > 0 ? "\($0)" : "" } ?? "")
.tag(p)
}
private func row(_ feed: IPX.Feed) -> some View {
HStack(spacing: 10) {
FeedArt(feed: feed, size: 30)
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
if feed.pinned {
Image(systemName: "pin.fill").font(.system(size: 8)).foregroundStyle(Glass.accent)
}
Text(feed.name).lineLimit(1).foregroundStyle(Glass.text)
}
Text("\(feed.entries) items · \(feed.downloaded) downloaded")
.font(.caption2).foregroundStyle(Glass.faint).lineLimit(1)
}
Spacer(minLength: 4)
if feed.lastError != nil {
// Something is wrong with the feed itself, which the counts cannot show.
Image(systemName: "exclamationmark.triangle.fill")
.font(.caption2).foregroundStyle(Glass.bad)
}
if feed.unread > 0 {
Text("\(feed.unread)")
.font(.caption2.monospacedDigit())
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Glass.accent.opacity(0.22), in: Capsule())
.foregroundStyle(Glass.accent)
}
}
.tag(LibraryStore.Place.feed(feed.id))
}
}
/// A feed's picture, or its initials. Two letters read as a thing on purpose; an empty grey
/// square reads as something that failed to load.
struct FeedArt: View {
let feed: IPX.Feed?
var size: CGFloat = 30
@State private var image: UIImage?
@Environment(\.api) private var api
var body: some View {
Group {
if let image {
Image(uiImage: image).resizable().aspectRatio(contentMode: .fill)
} else {
ZStack {
Rectangle().fill(Glass.accent.opacity(0.18))
Text(initials)
.font(.system(size: size * 0.38, weight: .semibold))
.foregroundStyle(Glass.accent)
}
}
}
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: size * 0.22, style: .continuous))
.task(id: feed?.image) {
guard let path = feed?.image, !path.isEmpty else { return }
image = await api.image(path)
}
}
private var initials: String {
let words = (feed?.name ?? "").split(separator: " ").prefix(2)
let letters = words.compactMap(\.first).map(String.init).joined()
return letters.isEmpty ? "?" : letters.uppercased()
}
}
private struct APIKey: EnvironmentKey {
static let defaultValue = API()
}
extension EnvironmentValues {
var api: API {
get { self[APIKey.self] }
set { self[APIKey.self] = newValue }
}
}