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 = [] /// 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 { 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 { 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 } } }