Files
ipodderx-app/ios/Sources/ItemListView.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

192 lines
7.4 KiB
Swift

import SwiftUI
/// The item table: the filter tabs, the rows, and what to do with one.
struct ItemListView: View {
@ObservedObject var store: LibraryStore
@ObservedObject var playback: Playback
var play: (IPX.Entry) -> Void
var body: some View {
VStack(spacing: 0) {
header
list
}
.background(Glass.Wash())
.navigationTitle(store.title)
#if !targetEnvironment(macCatalyst)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar { toolbar }
.searchable(text: $store.search, prompt: "Search \(store.title)")
}
// MARK: - header
private var header: some View {
VStack(spacing: 8) {
Picker("Show", selection: $store.filter) {
ForEach(IPX.Filter.allCases, id: \.self) { f in
Text(f.rawValue.capitalized).tag(f)
}
}
.pickerStyle(.segmented)
.disabled(store.place == .listening)
HStack(spacing: 6) {
Text(summary).font(.caption).foregroundStyle(Glass.faint)
Spacer()
if store.loading { ProgressView().controlSize(.mini) }
}
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.glassPane(Glass.sticky)
}
private var summary: String {
if let failure = store.failure { return failure }
let unread = store.entries.filter { !$0.read }.count
return "\(store.total) items, \(unread) unread on this page"
}
// MARK: - rows
private var list: some View {
List(selection: $store.selected) {
ForEach(store.entries) { entry in
row(entry)
.tag(entry.id)
.onAppear {
// Paging by the last row rather than a button: fifty at a time is the
// server's page size, and a long list should not need a tap to continue.
if entry.id == store.entries.last?.id { store.loadMore() }
}
}
if store.canLoadMore {
HStack { Spacer(); ProgressView(); Spacer() }
.listRowBackground(Color.clear)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.refreshable { await store.refreshFeeds(); store.reload() }
.overlay { if store.entries.isEmpty && !store.loading { empty } }
}
/// Written out rather than ContentUnavailableView, which is iOS 17 and this still runs on 16.
private var empty: some View {
VStack(spacing: 8) {
Image(systemName: store.search.isEmpty ? "tray" : "magnifyingglass")
.font(.largeTitle).foregroundStyle(Glass.faint)
Text(store.search.isEmpty ? "Nothing here" : "No matches")
.font(.headline).foregroundStyle(Glass.dim)
Text(store.search.isEmpty ? "Nothing in this view yet."
: "Nothing matches \u{201c}\(store.search)\u{201d}.")
.font(.footnote).foregroundStyle(Glass.faint)
}
.multilineTextAlignment(.center)
.padding()
}
private func row(_ entry: IPX.Entry) -> some View {
HStack(spacing: 10) {
// The unread dot, and the EQ bars in its place for whatever is playing, as the page
// marks the current item.
Group {
if playback.nowPlaying?.guid == entry.guid {
Image(systemName: "waveform").foregroundStyle(Glass.highlight)
} else if !entry.read {
Circle().fill(Glass.highlight).frame(width: 8, height: 8)
} else {
Color.clear.frame(width: 8, height: 8)
}
}
.frame(width: 14)
VStack(alignment: .leading, spacing: 2) {
Text(entry.title ?? "(untitled)")
.font(.callout.weight(entry.read ? .regular : .semibold))
.foregroundStyle(entry.read ? Glass.dim : Glass.text)
.lineLimit(2)
Text(subtitle(entry)).font(.caption2).foregroundStyle(Glass.faint).lineLimit(1)
}
Spacer(minLength: 6)
if entry.flagged {
Image(systemName: "pin.fill").font(.caption2).foregroundStyle(Glass.accent)
}
if entry.playable != nil {
Button { play(entry) } label: { Image(systemName: "play.circle") }
.buttonStyle(.plain).foregroundStyle(Glass.accent)
.accessibilityLabel("Play")
}
}
.padding(.vertical, 3)
.listRowBackground(Color.clear)
.contentShape(Rectangle())
.swipeActions(edge: .leading) {
Button { store.setRead(entry, !entry.read) } label: {
Label(entry.read ? "Unread" : "Read",
systemImage: entry.read ? "envelope.badge" : "checkmark")
}
.tint(Glass.accent)
}
.swipeActions(edge: .trailing) {
Button { store.setPinned(entry, !entry.flagged) } label: {
Label(entry.flagged ? "Unpin" : "Pin", systemImage: "pin")
}
.tint(Glass.highlight)
}
}
/// Feed, episode number, and how much is left if it has been started.
private func subtitle(_ entry: IPX.Entry) -> String {
var bits: [String] = []
if store.place.feedId == nil, let feed = store.feed(entry.feedId) { bits.append(feed.name) }
let number = [entry.season.map { "S\($0)" }, entry.episode.map { "E\($0)" }]
.compactMap { $0 }.joined()
if !number.isEmpty { bits.append(number) }
if let d = entry.duration, d > 0 {
bits.append(entry.position > 10 ? "\(clock(d - entry.position)) left" : clock(d))
}
if let published = entry.published {
bits.append(Date(timeIntervalSince1970: TimeInterval(published))
.formatted(date: .abbreviated, time: .omitted))
}
return bits.joined(separator: " · ")
}
private func clock(_ seconds: Int) -> String {
let (h, m, s) = (seconds / 3600, (seconds % 3600) / 60, seconds % 60)
return h > 0 ? String(format: "%d:%02d:%02d", h, m, s) : String(format: "%d:%02d", m, s)
}
// MARK: - toolbar
@ToolbarContentBuilder private var toolbar: some ToolbarContent {
ToolbarItemGroup {
Button { store.scan() } label: { Image(systemName: "arrow.trianglehead.2.clockwise") }
.help("Check for new items")
Button { store.readAll() } label: { Image(systemName: "checkmark.circle") }
.help("Mark everything here read")
Menu {
Picker("Sort by", selection: $store.sort) {
Text("Published").tag(IPX.Sort.published)
Text("Title").tag(IPX.Sort.title)
Text("Feed").tag(IPX.Sort.feed)
Text("Size").tag(IPX.Sort.size)
Text("Type").tag(IPX.Sort.type)
}
Picker("Order", selection: $store.direction) {
Text("Newest first").tag(IPX.Direction.desc)
Text("Oldest first").tag(IPX.Direction.asc)
}
} label: {
Image(systemName: "arrow.up.arrow.down")
}
.help("Sort")
}
}
}