Files
ipodderx-app/ios/Sources/ItemListView.swift
Ray Slakinski 292941524e A native item pane, with the show notes still HTML (#4)
The reading pane: the item, its files with play, download and delete, read
and pin in the toolbar, a link to the original, and the notes.

The notes are a web view, deliberately. They are feed-supplied HTML that
ipx has already run through ammonia, and there is no good native renderer
for that -- NSAttributedString(html:) is slow, single-threaded and ugly,
and writing a real one is a project. So this carries only the notes, with
just enough stylesheet to belong to the app: the system font at body size,
the label colours, links in the accent. Nothing else is imposed, because
the markup is the publisher's. A link opens in Safari rather than inside
the view, where it would replace the notes with somebody's website and
leave no way back.

Deleting asks first, and says when the file is shared: one file serves
everyone reading the feed, so removing it is not a private act.

Two goes at the navigation. A row with a selection binding on the list
highlighted and went nowhere -- the binding takes the tap before the link
sees it. Without the binding, NavigationLink(value:) with a matching
navigationDestination still pushed nothing and gave no reason, so the link
carries its own destination now, which has nothing to get wrong.

Fourteen tests pass. The new one opens an item and looks for its notes, its
delete button and its read control, so a pane that renders empty fails
rather than passing quietly.

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

201 lines
8.0 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
/// The file asked for, or nil for the item's first playable one.
var play: (IPX.Entry, IPX.Enclosure?) -> 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 {
// No selection binding: it swallowed the tap before the NavigationLink could act on
// it, so a row highlighted and went nowhere. Navigation is the link's job.
List {
ForEach(store.entries) { entry in
// A destination built here rather than a value matched to one elsewhere:
// the value form pushed nothing and gave no reason, and there is nothing to
// get wrong about a link that carries its own destination.
NavigationLink {
ItemDetailView(store: store, playback: playback, entry: entry, play: play)
} label: {
row(entry)
}
.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, nil) } 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")
}
}
}