Two changes. Tapping a card did nothing. LibraryCard carried its own press-scale effect via onLongPressGesture(minimumDuration: 0), and a zero-duration long press fires immediately and swallows the tap the host attaches. It was invisible in the prototype because nothing was listening for taps there. LibraryCard is now purely presentational and the grid wraps it in a Button with the existing RowPressStyle, which gets the same scale without competing for the gesture. The list now speaks the same design as the cards: paper ground, the color chip where the favicon was, serif quoted title, monospaced domain/date, serif-italic AI summary, monospaced tag chips. Everything the old row carried is still there — unread state, excerpt, tags, podcast affordance, reading progress — along with all swipe actions, since it is still a List. BookmarkListRow takes a style rather than being rewritten, because the Tags and Search screens use the same row and should not be silently restyled by a change aimed at the bookmarks screen. The tag filter strip moved up to BookmarksView. Both layouts speak the same language now, so the strip belongs to the screen rather than to one mode — which also retires the rule that leaving cards mode had to clear the tag filter to stop it becoming invisible. The tag chip strip fades at its trailing edge; without it a tag clipped mid-word reads as broken text rather than as something scrollable. Verified in both layouts and both color schemes against the live server. Full suite passes (16 tests). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
165 lines
6.3 KiB
Swift
165 lines
6.3 KiB
Swift
import SwiftUI
|
|
|
|
/// The library's list presentation. Carries everything `BookmarkRow` carried —
|
|
/// unread state, excerpt, tags, podcast affordance, reading progress — in the
|
|
/// paper vocabulary. The favicon becomes the color chip, so the same swatch
|
|
/// that identifies a card identifies its row.
|
|
struct LibraryListRow: View {
|
|
let bookmark: Bookmark
|
|
var readingProgress: Double = 0
|
|
var onPodcast: (() -> Void)? = nil
|
|
|
|
@State private var podcastTapCount = 0
|
|
@State private var podcastCached = false
|
|
|
|
private var item: LibraryItem { LibraryItem(bookmark: bookmark) }
|
|
|
|
/// Compact, static relative date ("6 min ago"). Using a formatter instead of
|
|
/// `Text(_, style: .relative)` avoids the live per-second ticking timer.
|
|
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
|
let f = RelativeDateTimeFormatter()
|
|
f.unitsStyle = .abbreviated
|
|
f.dateTimeStyle = .named
|
|
return f
|
|
}()
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.fill(item.swatch)
|
|
.frame(width: 26, height: 34)
|
|
.overlay(alignment: .topLeading) {
|
|
if bookmark.unread {
|
|
// Ink, not blue: the palette is the only color the
|
|
// library spends, and an accent dot would compete with
|
|
// the chip it sits on.
|
|
Circle()
|
|
.fill(Paper.ink)
|
|
.frame(width: 7, height: 7)
|
|
.overlay(Circle().stroke(Paper.sheet, lineWidth: 1.5))
|
|
.offset(x: -3, y: -3)
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
Text(item.listDisplay)
|
|
.font(.system(size: 14, design: .serif))
|
|
.foregroundStyle(Paper.ink)
|
|
.lineLimit(2)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
HStack(spacing: 5) {
|
|
Text(item.source)
|
|
Text("·")
|
|
Text(Self.relativeFormatter.localizedString(
|
|
for: bookmark.dateAdded, relativeTo: Date()
|
|
))
|
|
}
|
|
.font(.system(size: 10, design: .monospaced))
|
|
.foregroundStyle(Paper.ink.opacity(0.45))
|
|
.lineLimit(1)
|
|
|
|
if let excerpt = rowExcerpt {
|
|
Text(excerpt.text)
|
|
.font(.system(size: 12, design: .serif))
|
|
.italic(excerpt.isAI)
|
|
.foregroundStyle(Paper.ink.opacity(0.6))
|
|
.lineLimit(2)
|
|
}
|
|
|
|
if !effectiveTags.isEmpty {
|
|
tagRow
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 6)
|
|
|
|
if let onPodcast {
|
|
Button {
|
|
podcastTapCount += 1
|
|
onPodcast()
|
|
} label: {
|
|
Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle")
|
|
.font(.system(size: 17, weight: .light))
|
|
.foregroundStyle(Paper.ink.opacity(podcastCached ? 0.75 : 0.3))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.sensoryFeedback(.impact(weight: .medium), trigger: podcastTapCount)
|
|
}
|
|
}
|
|
.padding(.vertical, 12)
|
|
.contentShape(Rectangle())
|
|
.overlay(alignment: .bottom) {
|
|
if readingProgress > 0.02 {
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
Rectangle().fill(Paper.ink.opacity(0.1))
|
|
Rectangle()
|
|
.fill(Paper.ink.opacity(0.5))
|
|
.frame(width: geo.size.width * min(readingProgress, 1))
|
|
}
|
|
}
|
|
.frame(height: 2)
|
|
}
|
|
}
|
|
.task(id: bookmark.url) {
|
|
// Stat the podcast cache off the render path: once per appearance
|
|
// (and when the URL changes), not on every `body` recomputation.
|
|
let path = ClaudeService.cachedPodcastURL(for: bookmark.url).path
|
|
podcastCached = await Task.detached { FileManager.default.fileExists(atPath: path) }.value
|
|
}
|
|
}
|
|
|
|
private var tagRow: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 5) {
|
|
ForEach(effectiveTags, id: \.self) { tag in
|
|
Text(tag)
|
|
.font(.system(size: 10, design: .monospaced))
|
|
.foregroundStyle(Paper.ink.opacity(0.55))
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.stroke(Paper.rule.opacity(0.6), lineWidth: 0.6)
|
|
)
|
|
}
|
|
}
|
|
.padding(.vertical, 1)
|
|
}
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
// Without this the strip clips a tag mid-word at the trailing edge and
|
|
// reads as broken text rather than as something you can scroll.
|
|
.mask(
|
|
LinearGradient(
|
|
stops: [
|
|
.init(color: .black, location: 0),
|
|
.init(color: .black, location: 0.9),
|
|
.init(color: .clear, location: 1),
|
|
],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
)
|
|
}
|
|
|
|
/// Excerpt shown under the title: prefer the AI summary (italic), else the
|
|
/// page's scraped description / user note. nil hides the line entirely.
|
|
private var rowExcerpt: (text: String, isAI: Bool)? {
|
|
if let s = bookmark.aiSummary?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty {
|
|
return (s, true)
|
|
}
|
|
if let e = bookmark.contentExcerpt {
|
|
return (e, false)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private var effectiveTags: [String] {
|
|
let base = bookmark.tagNames
|
|
let ai = bookmark.aiTags ?? []
|
|
let extra = ai.filter { !base.contains($0) }.prefix(3)
|
|
return (base + extra).prefix(6).map { $0 }
|
|
}
|
|
}
|