pageta-native/Sources/Features/ArticleDetailView.swift
Till JS ca4b17b1a3 feat: ArticleMediaArchived-Mirror + archivierte Vorschaubilder (Liste + Reader-Header)
Spiegel von @mana/shared-schemas/pageta@0.1.21 (Web = SOT):
- ArticleMediaArchivedPayload + Reducer-Case (non-destruktiv, if-let
  wie warning); ArticleEnriched clobbert imageUrl nicht mehr
- ArticleState.htmlContent; stateToArticle mappt es (war nil)
- ArticleExtractPreview dekodiert imageUrl (og:image-Hotlink)
- archiveMedia(): POST /articles/archive-media, fire-and-forget nach
  Save/Enrich; NUR die archivierte media.mana.how-URL wird emittiert,
  nie der Hotlink; Delete-Race-Guard wie im Web-Client
- MediaURL.variant (thumb/medium/large, Spiegel von media-url.ts)
- Lese-Liste: 56pt-Thumbnail; Reader-Header: Lead-Image (medium, 200pt)
- 5 neue Reducer-/MediaURL-Tests (54/54 grün, iPhone-17-Simulator)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:52:17 +02:00

1448 lines
47 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import ManaCore
import Observation
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
/// Reader-Detail-View. π-3 Plain-Text-Spike + π-4 Highlights/Tags.
///
/// **HTML→AttributedString-Spike** (π-3): zugunsten Plain-Text aus
/// Mozilla-Readability (`article.content` mit `\n\n`-Paragraphen).
/// `htmlContent` wäre Round-Trip über NSAttributedString — langsam +
/// Crash-Risiko bei komplexem CSS. Headings/Bilder/Quotes/Code/Links
/// werden flach gerendert; Iteration 2 via Server-Markdown-Convert
/// oder Block-JSON.
///
/// **Markieren** (π-4, überarbeitet 2026-05-25): Text auswählen → eine
/// schwebende Farb-Palette (`HighlightSelectionPalette`) erscheint direkt
/// an der Auswahl (Books-Stil), ein Tipp markiert. Kein Offset-Tracking —
/// Highlight matcht auf Text-Identität; web-erstellte Highlights mit
/// echten Offsets bleiben les- und löschbar. Schreibfehler (z.B. Session
/// abgelaufen) werden jetzt als Alert sichtbar statt still verschluckt.
struct ArticleDetailView: View {
@State private var articleId: String
init(articleId: String) {
_articleId = State(initialValue: articleId)
}
@Environment(AuthClient.self) private var auth
@Environment(\.dismiss) private var dismiss
private var api: PagetaAPI {
PagetaAPI(auth: auth)
}
@State private var article: Article?
@State private var highlights: [Highlight] = []
@State private var loadState: LoadState = .loading
@State private var textSelection: TextSelection?
@State private var paletteSize: CGSize = .zero
@State private var writeError: String?
@State private var editingNoteFor: Highlight?
@State private var showTagSheet = false
@State private var tagInput = ""
@State private var siblings: [Article] = []
// MARK: - In-Artikel-Suche
// Eigene Find-in-page statt System-UIFindInteraction/NSTextFinder (die
// eine selbst-scrollende TextView voraussetzen — hier bewusst nicht).
// Treffer als NSRange über ArticleBodyTextView.cleanedBody(content); die
// Hervorhebung + Rect-Brücke fürs Scrollen lebt in ArticleBodyTextView.
/// Bündig zum Web-Reader (Cmd/Ctrl+F-App-Suche statt Browser-Suche).
@State private var searchActive = false
@State private var searchQuery = ""
@State private var searchMatches: [NSRange] = []
@State private var activeMatchIndex: Int?
@State private var searchDebounce: Task<Void, Never>?
@FocusState private var searchFieldFocused: Bool
/// Brücke, über die der Body das textview-lokale Rect eines Treffers liefert.
@State private var bodyController = ArticleBodyController()
/// Hält Scroll-Offset + Body-Position für die Scroll-zu-Treffer-Mathematik.
/// Bewusst eine **plain class** (kein @Observable): so triggern die häufigen
/// Scroll-Updates KEINE SwiftUI-Invalidierung des teuren Reader-Bodys.
@State private var scrollTracker = ReaderScrollTracker()
/// Named-Coordinate-Space, in dem die Body-Position relativ zum Reader
/// gemessen wird (für Scroll-zu-Treffer).
private static let readerSpace = "pagetaReaderSpace"
// MARK: - Inhalts-Extraktion (geteilte Links)
/// Läuft gerade ein Extraktions-Versuch für diesen Artikel?
@State private var isExtracting = false
/// Letzte Extraktions-Fehlermeldung (nil = noch keiner / erfolgreich).
@State private var extractionError: String?
/// Artikel-IDs, für die der automatische Einmal-Versuch in `load()` schon
/// lief — verhindert eine Schleife, falls die Extraktion leer/fehlerhaft
/// bleibt. Der manuelle Retry-Button umgeht das bewusst.
@State private var autoExtractTried: Set<String> = []
// MARK: - Lese-Fortschritt
/// Spiegelt die Web-Mechanik (apps/web/.../article/[id]/+page.svelte):
/// Scroll-Position 0..1 messen, debounced (1,5 s) als
/// ArticleProgressUpdated-Event syncen, gespeicherte Position beim
/// Öffnen einmalig wiederherstellen.
@State private var scrollPosition = ScrollPosition(edge: .top)
/// Live-Fortschritt für Balken + Button-Füllung. **Bewusst ein
/// `@Observable`-Model statt `@State Double`:** so invalidiert eine
/// Scroll-Aktualisierung nur die beiden Mini-Views, die `fraction` lesen
/// (ReadingProgressBar + ReadProgressButton) — NICHT den ganzen Reader-Body
/// mit der teuren ArticleBodyTextView-Brücke. Sonst ruckelt jedes 2%-Tick
/// das Scrollen (UITextView-Neuaufbau pro Body-Re-Eval).
@State private var progress = ReadingProgressModel()
/// One-shot-Guard: gespeicherte Scroll-Position erst springen, sobald
/// das Layout eine echte Scroll-Range kennt — danach Tracking scharf.
@State private var didRestoreScroll = false
@State private var progressFlushTask: Task<Void, Never>?
enum LoadState: Equatable {
case loading
case loaded
case error(String)
}
/// Messpunkt aus `ScrollGeometry`: normierter Fortschritt + scrollbare
/// Range. Equatable, damit `onScrollGeometryChange` nur bei echter
/// Änderung feuert.
private struct ScrollProgressSample: Equatable {
var value: Double
var range: CGFloat
var offsetY: CGFloat
}
var body: some View {
Group {
if let article {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
header(for: article)
if article.warning == "probable_consent_wall" {
consentWallBanner(for: article)
}
ArticleSummarySection(article: article)
if !article.tags.isEmpty {
tagsSection(for: article)
}
if !highlights.isEmpty {
highlightsSection
}
if article.content.isEmpty {
pendingExtractionNotice(for: article)
} else {
ArticleBodyTextView(
text: article.content,
highlights: highlights,
onSelectionChange: { selection in
textSelection = selection
},
selectionActive: textSelection != nil,
searchMatches: searchMatches,
activeMatchIndex: activeMatchIndex,
controller: bodyController
)
.background(
// Body-Position relativ zum Reader messen — zusammen
// mit dem Scroll-Offset ergibt das die absolute
// Scroll-Zielposition eines Treffers.
GeometryReader { geo in
Color.clear
.onChange(
of: geo.frame(in: .named(Self.readerSpace)).minY,
initial: true
) { _, newY in
scrollTracker.bodyMinY = newY
}
}
)
}
footerActions
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
.frame(maxWidth: 720, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(PagetaTheme.background)
// Etwas Luft am Artikelende, damit die letzte Zeile nicht am
// 2pt-Fortschrittsbalken/Bildschirmrand klebt. (Früher 60 für
// die floating Tab-Bar — die ist im Reader jetzt ausgeblendet.)
.contentMargins(.bottom, 24, for: .scrollContent)
.scrollPosition($scrollPosition)
.onScrollGeometryChange(for: ScrollProgressSample.self) { geo in
let range = geo.contentSize.height - geo.containerSize.height
let value = range > 0
? min(1, max(0, geo.contentOffset.y / range))
: 0
return ScrollProgressSample(
value: value,
range: range,
offsetY: geo.contentOffset.y
)
} action: { _, new in
// Roher Offset für die Scroll-zu-Treffer-Mathematik (kein
// SwiftUI-Invalidate, da plain class).
scrollTracker.scrollY = new.offsetY
handleScroll(fraction: new.value, range: new.range)
}
.overlay(alignment: .bottom) {
readingProgressBar
.ignoresSafeArea(edges: .bottom)
}
.overlay { selectionPaletteOverlay }
.overlay(alignment: .top) { searchBarOverlay }
.coordinateSpace(.named(Self.readerSpace))
.onDisappear { flushProgressNow() }
} else {
switch loadState {
case .loading:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case let .error(message):
ContentUnavailableView(
PagetaStrings.detailLoadError,
systemImage: "exclamationmark.triangle",
description: Text(message)
)
case .loaded:
ContentUnavailableView(
PagetaStrings.detailEmpty,
systemImage: "doc.text",
description: Text(PagetaStrings.detailNoContent)
)
}
}
}
.navigationTitle("")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
// TabBar im Reader ausblenden: sonst sitzt der Lese-Fortschritts-
// Balken (.overlay unten) über dem Tab-Wechsler statt am unteren
// Bildschirmrand. Beim Zurück-Poppen kommt die TabBar wieder.
.toolbar(.hidden, for: .tabBar)
#endif
.toolbar { toolbarContent }
.background { searchHotkey }
.sheet(isPresented: $showTagSheet) {
tagSheet
}
.sheet(item: $editingNoteFor) { highlight in
HighlightNoteSheet(
highlightText: highlight.text,
initialNote: highlight.note,
color: highlight.color,
onSave: { newNote in
await saveNote(for: highlight, newNote: newNote)
}
)
}
.task(id: articleId) {
await load()
}
.alert(
PagetaStrings.detailSaveFailed,
isPresented: writeErrorBinding,
presenting: writeError
) { _ in
Button(PagetaStrings.actionOk, role: .cancel) { writeError = nil }
} message: { message in
Text(message)
}
}
private var writeErrorBinding: Binding<Bool> {
Binding(
get: { writeError != nil },
set: { newValue in if !newValue { writeError = nil } }
)
}
/// Schwebende Auswahl-Palette (Books-Stil). Auf iOS an das Auswahl-
/// Rechteck verankert (oben, klappt bei Platzmangel unter die Auswahl);
/// auf macOS oben-zentriert.
@ViewBuilder
private var selectionPaletteOverlay: some View {
if let selection = textSelection {
#if os(iOS)
GeometryReader { proxy in
let container = proxy.frame(in: .global)
let position = palettePosition(
anchor: selection.anchorRect,
container: container,
size: paletteSize
)
HighlightSelectionPalette(
onPick: { color in
let text = selection.text
textSelection = nil
requestHighlight(text: text, color: color)
},
onCopy: {
UIPasteboard.general.string = selection.text
textSelection = nil
}
)
.fixedSize()
.background(
GeometryReader { geo in
Color.clear.preference(key: PaletteSizeKey.self, value: geo.size)
}
)
.offset(x: position.x, y: position.y)
// Erst nach der Größenmessung einblenden, sonst blitzt
// die Palette eine Frame lang oben-links auf.
.opacity(paletteSize == .zero ? 0 : 1)
}
.onPreferenceChange(PaletteSizeKey.self) { paletteSize = $0 }
#else
VStack {
HighlightSelectionPalette(
onPick: { color in
let text = selection.text
textSelection = nil
requestHighlight(text: text, color: color)
},
onCopy: {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(selection.text, forType: .string)
textSelection = nil
}
)
.padding(.top, 12)
Spacer()
}
.frame(maxWidth: .infinity)
#endif
}
}
#if os(iOS)
/// Positioniert die Palette mittig über der Auswahl (lokale Koordinaten
/// der GeometryReader-Fläche). Bei zu wenig Platz oben klappt sie unter
/// die Auswahl; horizontal + vertikal auf den sichtbaren Bereich geklemmt.
private func palettePosition(anchor: CGRect, container: CGRect, size: CGSize) -> CGPoint {
let gap: CGFloat = 10
let width = size.width
let height = size.height
let anchorMidX = anchor.midX - container.minX
let anchorTop = anchor.minY - container.minY
let anchorBottom = anchor.maxY - container.minY
var x = anchorMidX - width / 2
x = min(max(8, x), max(8, container.width - width - 8))
var y = anchorTop - height - gap
if y < 8 {
y = anchorBottom + gap
}
y = min(max(8, y), max(8, container.height - height - 8))
return CGPoint(x: x, y: y)
}
#endif
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItemGroup(placement: .primaryAction) {
if let article {
Menu {
Section("Status") {
ForEach(ArticleStatus.allCases) { status in
Button {
Task { await setStatus(status, for: article) }
} label: {
Label(status.label, systemImage: icon(for: status))
}
}
}
Section {
Button {
openSearch()
} label: {
Label(PagetaStrings.detailSearchInArticle, systemImage: "magnifyingglass")
}
Button {
tagInput = ""
showTagSheet = true
} label: {
Label(PagetaStrings.detailAddTag, systemImage: "tag")
}
if let url = URL(string: article.originalUrl) {
Link(destination: url) {
Label(PagetaStrings.actionOpenInBrowser, systemImage: "safari")
}
ShareLink(item: url) {
Label(PagetaStrings.actionShareShort, systemImage: "square.and.arrow.up")
}
}
}
} label: {
// iOS 18+ rendert Toolbar-Items eh in Glass-Capsules;
// `.circle` würde einen doppelten Kreis ergeben.
Image(systemName: "ellipsis")
}
}
}
}
// MARK: - In-Artikel-Suche (UI + Logik)
/// Schwebende Such-Leiste oben über dem Reader (Find-in-page-Stil).
@ViewBuilder
private var searchBarOverlay: some View {
if searchActive {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.callout)
.foregroundStyle(.secondary)
TextField(PagetaStrings.detailSearchInArticlePlaceholder, text: $searchQuery)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.focused($searchFieldFocused)
.submitLabel(.search)
.onSubmit { nextMatch() }
.onChange(of: searchQuery) { _, query in scheduleSearch(query) }
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
if !matchCountLabel.isEmpty {
Text(matchCountLabel)
.font(.caption)
.monospacedDigit()
.foregroundStyle(.secondary)
}
Button { prevMatch() } label: {
Image(systemName: "chevron.up")
}
.buttonStyle(.plain)
.disabled(searchMatches.isEmpty)
.accessibilityLabel(PagetaStrings.detailPrevHit)
Button { nextMatch() } label: {
Image(systemName: "chevron.down")
}
.buttonStyle(.plain)
.disabled(searchMatches.isEmpty)
.accessibilityLabel(PagetaStrings.detailNextHit)
Button { closeSearch() } label: {
Image(systemName: "xmark")
}
.buttonStyle(.plain)
.accessibilityLabel(PagetaStrings.searchClose)
}
.font(.callout)
.foregroundStyle(PagetaTheme.foreground)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(PagetaTheme.foreground.opacity(0.12), lineWidth: 0.5)
)
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
.padding(.horizontal, 12)
.padding(.top, 8)
}
}
/// Unsichtbarer Cmd/Ctrl+F-Hotkey — übernimmt die App-Suche (bündig zum
/// Web-Reader). Als 0×0-Button im Hintergrund, ohne sichtbare Affordance.
private var searchHotkey: some View {
Button(PagetaStrings.detailSearchInArticle) { openSearch() }
.keyboardShortcut("f", modifiers: .command)
.opacity(0)
.frame(width: 0, height: 0)
.accessibilityHidden(true)
}
private var matchCountLabel: String {
guard searchQuery.trimmingCharacters(in: .whitespaces).count >= 2 else { return "" }
guard !searchMatches.isEmpty else { return "0" }
return "\((activeMatchIndex ?? 0) + 1)/\(searchMatches.count)"
}
private func openSearch() {
searchActive = true
searchFieldFocused = true
}
private func closeSearch() {
searchDebounce?.cancel()
searchActive = false
searchFieldFocused = false
searchQuery = ""
searchMatches = []
activeMatchIndex = nil
}
/// Debounced Treffer-Berechnung (≈180 ms), damit ein langer Artikel nicht
/// bei jedem Tastendruck den ganzen AttributedString neu aufbaut.
private func scheduleSearch(_ query: String) {
searchDebounce?.cancel()
searchDebounce = Task {
try? await Task.sleep(for: .milliseconds(180))
if Task.isCancelled { return }
computeMatches(query)
}
}
/// Berechnet Treffer als NSRanges über **genau** den gerenderten Body-
/// String (`ArticleBodyTextView.cleanedBody`), damit die Offsets zu den
/// Attributen der TextView passen. Springt danach zum ersten Treffer.
private func computeMatches(_ query: String) {
let trimmed = query.trimmingCharacters(in: .whitespaces)
guard trimmed.count >= 2, let content = article?.content else {
searchMatches = []
activeMatchIndex = nil
return
}
let body = ArticleBodyTextView.cleanedBody(content) as NSString
var ranges: [NSRange] = []
var start = 0
while start < body.length {
let found = body.range(
of: trimmed,
options: .caseInsensitive,
range: NSRange(location: start, length: body.length - start)
)
if found.location == NSNotFound { break }
ranges.append(found)
start = found.location + max(1, found.length)
}
searchMatches = ranges
if ranges.isEmpty {
activeMatchIndex = nil
} else {
focusMatch(0)
}
}
private func nextMatch() {
guard !searchMatches.isEmpty else { return }
let current = activeMatchIndex ?? -1
focusMatch((current + 1) % searchMatches.count)
}
private func prevMatch() {
guard !searchMatches.isEmpty else { return }
let current = activeMatchIndex ?? 0
focusMatch((current - 1 + searchMatches.count) % searchMatches.count)
}
/// Setzt den aktiven Treffer und scrollt ihn unter die Such-Leiste.
///
/// Mathematik: das textview-LOKALE Treffer-Rect (`bodyController.localRect`)
/// plus die im `readerSpace` gemessene Body-Position plus der Scroll-Offset
/// ergeben die absolute Zielposition im Scroll-Content. `bodyMinY` ist
/// `bodyAbsoluteTop − scrollY`, also `bodyMinY + scrollY = bodyAbsoluteTop`.
private func focusMatch(_ index: Int) {
guard searchMatches.indices.contains(index) else { return }
activeMatchIndex = index
guard let localRect = bodyController.localRect?(searchMatches[index]) else { return }
// Zielabstand von der Oberkante: unter die schwebende Such-Leiste.
let targetTop: CGFloat = 96
let absoluteTop = scrollTracker.bodyMinY + scrollTracker.scrollY
let target = max(0, absoluteTop + localRect.minY - targetTop)
withAnimation(.easeInOut(duration: 0.25)) {
scrollPosition.scrollTo(y: target)
}
}
// MARK: - Rendering
/// Dünner Lese-Fortschritts-Balken oben über dem Reader (Web-Pendant:
/// fixierter Balken auf der Artikel-Seite). Liest `fraction` in einer
/// eigenen View, damit Scroll-Updates nur diesen Balken neu rendern.
private var readingProgressBar: some View {
ReadingProgressBar(progress: progress)
}
private func header(for article: Article) -> some View {
VStack(alignment: .leading, spacing: 8) {
// Lead-Image aus dem Archiv (ArticleMediaArchived) — nur wenn
// eins da ist; ohne Bild bleibt der Header wie bisher.
if let imageUrl = article.imageUrl,
let url = URL(string: MediaURL.variant(imageUrl, .medium))
{
AsyncImage(url: url) { phase in
if let image = phase.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
// Adaptiver Placeholder statt paper-cream (Dark-Mode).
Color.secondary.opacity(0.1)
}
}
// Feste Höhe statt maxHeight: AsyncImage hätte sonst die
// intrinsische Bildgröße und springt beim Laden.
.frame(maxWidth: .infinity)
.frame(height: 200)
.clipShape(RoundedRectangle(cornerRadius: 10))
.accessibilityHidden(true)
.padding(.bottom, 4)
}
HStack(spacing: 6) {
Text(article.siteName ?? "")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
if let mins = article.readingTimeMinutes {
Text("•").foregroundStyle(.secondary)
Text(PagetaStrings.metaMinRead(mins))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(article.status.label)
.font(.caption2.weight(.medium))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(PagetaTheme.surfaceHover, in: Capsule())
}
Text(article.displayTitle)
.font(.system(.title2, design: .serif, weight: .semibold))
.foregroundStyle(PagetaTheme.foreground)
if let author = article.author, !author.isEmpty {
Text("von \(author)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
/// Warnt, wenn der Server-Extract auf eine Cookie-Wand statt einen
/// Artikel gefallen ist (`looksLikeConsentWall`). Bietet
/// Original-Link zum Browser-Open an, da der gespeicherte Text
/// wertlos ist.
private func consentWallBanner(for article: Article) -> some View {
VStack(alignment: .leading, spacing: 8) {
Label("Wahrscheinlich Cookie-Wand", systemImage: "exclamationmark.triangle.fill")
.font(.caption.weight(.semibold))
.foregroundStyle(.orange)
Text(
PagetaStrings.detailConsentWallBody
)
.font(.callout)
.foregroundStyle(PagetaTheme.foreground)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 8) {
if let url = URL(string: article.originalUrl) {
Link(destination: url) {
Label(PagetaStrings.detailConsentWallOpen, systemImage: "safari")
.font(.callout.weight(.medium))
}
}
Spacer(minLength: 8)
Button {
Task { await dismissWarning(for: article) }
} label: {
Text(PagetaStrings.detailDismissHint)
.font(.callout)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(.orange.opacity(0.4), lineWidth: 1)
)
}
/// Platzhalter für local-first geteilte Links ohne extrahierten Inhalt.
/// Drei ehrliche Zustände statt des alten pauschalen „lädt automatisch
/// nach": läuft gerade / fehlgeschlagen (mit Grund) / noch nicht versucht.
/// In allen Fällen: manueller „Erneut verarbeiten"-Button + Original-Link.
private func pendingExtractionNotice(for article: Article) -> some View {
VStack(alignment: .leading, spacing: 10) {
if isExtracting {
Label(PagetaStrings.detailLoading, systemImage: "arrow.triangle.2.circlepath")
.font(.callout.weight(.semibold))
.foregroundStyle(.secondary)
} else if let extractionError {
Label(PagetaStrings.detailContentLoadError, systemImage: "exclamationmark.triangle")
.font(.callout.weight(.semibold))
.foregroundStyle(.orange)
Text(extractionError)
.font(.callout)
.foregroundStyle(PagetaTheme.foreground)
.fixedSize(horizontal: false, vertical: true)
Text(
PagetaStrings.detailConsentTip
)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} else {
Label(PagetaStrings.detailContentNotLoaded, systemImage: "doc.text.magnifyingglass")
.font(.callout.weight(.semibold))
.foregroundStyle(.secondary)
Text(
PagetaStrings.detailContentNotLoadedBody
)
.font(.callout)
.foregroundStyle(PagetaTheme.foreground)
.fixedSize(horizontal: false, vertical: true)
}
HStack(spacing: 12) {
Button {
Task { await extractContent(force: true) }
} label: {
Label(PagetaStrings.detailReprocess, systemImage: "arrow.clockwise")
.font(.callout.weight(.medium))
}
.buttonStyle(.borderedProminent)
.tint(PagetaTheme.primary)
.disabled(isExtracting)
if let url = URL(string: article.originalUrl) {
Link(destination: url) {
Label(PagetaStrings.actionOpenInBrowser, systemImage: "safari")
.font(.callout.weight(.medium))
}
}
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(PagetaTheme.surfaceHover, in: RoundedRectangle(cornerRadius: 10))
}
/// Tag-Pills mit Remove-Action. Read-back kam mit der Article-DTO-
/// Erweiterung 2026-05-22 — vorher konnte Native Tags nur schreiben,
/// nicht anzeigen.
private func tagsSection(for article: Article) -> some View {
VStack(alignment: .leading, spacing: 8) {
Label(PagetaStrings.tagsLabel, systemImage: "tag")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
// Horizontal-Scroll statt Wrap, weil iOS 16 keinen built-in
// FlowLayout hat — bei vielen Tags scrollt der User sideways.
// Bei 1-5 Tags (typischer Fall) passt eh alles in eine Zeile.
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(article.tags, id: \.self) { tag in
HStack(spacing: 4) {
Text(tag)
.font(.caption)
.foregroundStyle(PagetaTheme.foreground)
Button {
Task { await removeTag(tag, from: article) }
} label: {
Image(systemName: "xmark.circle.fill")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
.accessibilityLabel("Tag entfernen")
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(
Capsule().fill(PagetaTheme.surfaceHover)
)
.overlay(
Capsule().stroke(PagetaTheme.foreground.opacity(0.12), lineWidth: 0.5)
)
}
}
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(PagetaTheme.surfaceHover.opacity(0.4), in: RoundedRectangle(cornerRadius: 8))
}
private var highlightsSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label(PagetaStrings.detailMarkerCount(highlights.count), systemImage: "highlighter")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
ForEach(highlights) { highlight in
highlightRow(highlight)
}
}
.padding(12)
.background(PagetaTheme.surfaceHover, in: RoundedRectangle(cornerRadius: 8))
}
private func highlightRow(_ highlight: Highlight) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 8) {
colorMenu(for: highlight) {
RoundedRectangle(cornerRadius: 2)
.fill(highlight.color.swiftUIColor)
.frame(width: 4)
}
.buttonStyle(.plain)
Text(highlight.text)
.font(.callout)
.foregroundStyle(PagetaTheme.foreground)
.lineLimit(3)
Spacer()
Button {
editingNoteFor = highlight
} label: {
Image(systemName: highlight.note == nil ? "note.text.badge.plus" : "note.text")
.foregroundStyle(highlight.note == nil ? Color.secondary : PagetaTheme
.primary)
}
.buttonStyle(.plain)
.accessibilityLabel(highlight.note == nil ? PagetaStrings.noteAdd : PagetaStrings.noteEdit)
Button {
Task { await deleteHighlight(highlight) }
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
.accessibilityLabel(PagetaStrings.markerDelete)
}
if let note = highlight.note, !note.isEmpty {
HStack(alignment: .top, spacing: 6) {
Image(systemName: "note.text")
.font(.caption2)
.foregroundStyle(.secondary)
Text(note)
.font(.footnote)
.foregroundStyle(PagetaTheme.foreground.opacity(0.85))
}
.padding(.leading, 12)
}
}
.padding(8)
.background(
highlight.color.swiftUIColor.opacity(0.15),
in: RoundedRectangle(cornerRadius: 6)
)
}
private var footerActions: some View {
VStack(spacing: 12) {
Divider()
.padding(.vertical, 4)
HStack(spacing: 10) {
readToggleButton
Button {
Task { await archiveAndDismiss() }
} label: {
Label(PagetaStrings.actionArchive, systemImage: "archivebox")
.font(.callout.weight(.medium))
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(PagetaTheme.primary)
}
HStack(spacing: 10) {
Button {
if let prev = previousSibling { jumpTo(prev) }
} label: {
HStack(spacing: 6) {
Image(systemName: "chevron.left")
VStack(alignment: .leading, spacing: 2) {
Text("Vorheriger")
.font(.caption2)
.foregroundStyle(.secondary)
Text(previousSibling?.displayTitle ?? "—")
.font(.footnote.weight(.medium))
.foregroundStyle(PagetaTheme.foreground)
.lineLimit(1)
.truncationMode(.tail)
}
Spacer(minLength: 0)
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(PagetaTheme.surfaceHover, in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.disabled(previousSibling == nil)
.opacity(previousSibling == nil ? 0.4 : 1)
Button {
if let next = nextSibling { jumpTo(next) }
} label: {
HStack(spacing: 6) {
Spacer(minLength: 0)
VStack(alignment: .trailing, spacing: 2) {
Text(PagetaStrings.detailNext)
.font(.caption2)
.foregroundStyle(.secondary)
Text(nextSibling?.displayTitle ?? "—")
.font(.footnote.weight(.medium))
.foregroundStyle(PagetaTheme.foreground)
.lineLimit(1)
.truncationMode(.tail)
}
Image(systemName: "chevron.right")
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .trailing)
.background(PagetaTheme.surfaceHover, in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.disabled(nextSibling == nil)
.opacity(nextSibling == nil ? 0.4 : 1)
}
}
.padding(.top, 12)
}
private func icon(for status: ArticleStatus) -> String {
switch status {
case .unread: "circle"
case .reading: "book"
case .finished: "checkmark.circle"
case .archived: "archivebox"
}
}
private var tagSheet: some View {
NavigationStack {
Form {
Section("Tag") {
TextField(PagetaStrings.detailTagPlaceholder, text: $tagInput)
.autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
}
Section {
Text(
PagetaStrings.detailTagsHint
)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.navigationTitle(PagetaStrings.detailAddTag)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(PagetaStrings.actionCancel) { showTagSheet = false }
}
ToolbarItem(placement: .confirmationAction) {
Button(PagetaStrings.actionSave) {
Task { await addTag(tagInput) }
}
.disabled(tagInput.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
}
// MARK: - Actions
private func load() async {
loadState = .loading
// Extraktions-Status für den (evtl. neuen) Artikel zurücksetzen.
extractionError = nil
isExtracting = false
do {
let fetched = try await api.fetchArticle(id: articleId)
article = fetched
// Fortschritts-Tracking für diesen Artikel scharf machen:
// Balken auf gespeicherten Stand, Restore neu freischalten.
progress.fraction = fetched.readingProgress
didRestoreScroll = false
loadState = .loaded
if fetched.status == .unread {
try? await api.updateArticle(id: fetched.id, status: .reading)
article?.status = .reading
}
await loadHighlights()
await loadSiblings()
// Geteilter Link ohne Inhalt: einmal aktiv nachladen, statt nur
// passiv den Hinweis zu zeigen. Genau hier steht der Nutzer.
if fetched.content.isEmpty {
await extractContent(force: false)
}
} catch {
loadState = .error(error.localizedDescription)
}
}
/// Lädt alle nicht-archivierten Artikel für Vorheriger/Nächster-
/// Navigation. Sortier-Reihenfolge passt zu LeseListeTabView:
/// Lesend → Ungelesen → Gelesen, innerhalb jeweils savedAt desc.
/// Reine Local-Replay-Operation, kein Netzwerk.
private func loadSiblings() async {
do {
let all = try await api.fetchArticles(limit: 500)
let visible = all.filter { $0.status != .archived }
let reading = visible.filter { $0.status == .reading }
let unread = visible.filter { $0.status == .unread }
let finished = visible.filter { $0.status == .finished }
siblings = reading + unread + finished
} catch {
siblings = []
}
}
private var currentSiblingIndex: Int? {
siblings.firstIndex(where: { $0.id == articleId })
}
private var previousSibling: Article? {
guard let idx = currentSiblingIndex, idx > 0 else { return nil }
return siblings[idx - 1]
}
private var nextSibling: Article? {
guard let idx = currentSiblingIndex, idx + 1 < siblings.count else { return nil }
return siblings[idx + 1]
}
/// Wechselt zu einem Geschwister-Artikel ohne den Navigation-Stack
/// zu erweitern — wir ersetzen einfach den articleId-State und das
/// `.task(id:)` lädt neu.
private func jumpTo(_ sibling: Article) {
// Ausstehenden Fortschritt des aktuellen Artikels noch wegschreiben,
// bevor wir die Identität wechseln.
flushProgressNow()
// Offene Suche zeigt sonst auf Ranges des alten Artikels.
closeSearch()
articleId = sibling.id
article = nil
highlights = []
progress.fraction = 0
didRestoreScroll = false
}
/// Toggle-Button "Gelesen" ↔ "Wieder lesen" mit Fortschritts-Füllung.
/// In eigene View ausgelagert (liest `fraction` selbst), damit Scroll-
/// Updates nicht den ganzen Reader-Body neu auswerten.
private var readToggleButton: some View {
ReadProgressButton(
progress: progress,
isFinished: article?.status == .finished,
onToggle: { Task { await toggleFinished() } }
)
}
/// Toggle zwischen .finished und .reading. Lokal optimistic, kein
/// Dismiss — User bleibt im Reader und kann weiterscrollen, im
/// Vorheriger/Nächster navigieren oder per System-Back zur Liste.
private func toggleFinished() async {
guard let current = article else { return }
let newStatus: ArticleStatus = (current.status == .finished) ? .reading : .finished
do {
try await api.updateArticle(id: current.id, status: newStatus)
article?.status = newStatus
// Siblings-Cache mitziehen, damit Prev/Next die Liste in der
// gleichen Reihenfolge zeigt, falls sich die Sektion ändert.
if let idx = siblings.firstIndex(where: { $0.id == current.id }) {
siblings[idx].status = newStatus
}
} catch {
writeError = writeErrorMessage(error)
}
}
/// Archiviert und navigiert zurück zur Liste.
private func archiveAndDismiss() async {
guard let article else { return }
do {
try await api.updateArticle(id: article.id, status: .archived)
dismiss()
} catch {
writeError = writeErrorMessage(error)
}
}
private func loadHighlights() async {
do {
highlights = try await api.fetchHighlights(articleId: articleId)
} catch {
// Highlights sind sekundär — beim Fehler nichts zeigen,
// nicht den ganzen Reader verbarrikadieren.
highlights = []
}
}
private func setStatus(_ status: ArticleStatus, for article: Article) async {
do {
try await api.updateArticle(id: article.id, status: status)
self.article?.status = status
if status == .archived {
dismiss()
}
} catch {
writeError = writeErrorMessage(error)
}
}
/// Local-First-Markieren (E-4.5): schreibt sofort lokal — ohne Login.
/// Die `EventSyncEngine` stempelt eine anonyme Identität; beim späteren
/// Login liftet der stille Claim (`PagetaNativeApp` → `claimOnSignIn`)
/// die Markierung transparent in den Account. Kein Login-Gate mehr.
private func requestHighlight(text: String, color: HighlightColor) {
Task { await addHighlight(text: text, color: color) }
}
private func addHighlight(text: String, color: HighlightColor) async {
do {
try await api.addHighlight(articleId: articleId, text: text, color: color)
await loadHighlights()
} catch {
// Fallback-Sichtbarkeit: Schreibfehler, die nicht schon vom
// Login-Gate abgefangen wurden (z.B. abgelaufene Session, die
// noch `.signedIn` meldet, oder Server-/Netzfehler). Vorher
// landete das in `loadState`, dessen UI aber nur bei `article
// == nil` rendert → stiller Fehlschlag.
writeError = writeErrorMessage(error)
}
}
/// Übersetzt einen Schreibfehler in eine nutzersichtbare Meldung für den
/// `writeError`-Alert. Neutral formuliert, weil derselbe Helfer alle
/// mutierenden Pfade bedient (Markierung, Notiz, Status, Tags).
private func writeErrorMessage(_ error: Error) -> String {
if let syncError = error as? PagetaSyncError {
switch syncError {
case .notAuthenticated:
return PagetaStrings.detailErrorSessionExpired
case .eventSyncUnavailable:
return PagetaStrings.detailErrorSyncNotReady
default:
return syncError.localizedDescription
}
}
if let authError = error as? AuthError, case .notSignedIn = authError {
return PagetaStrings.detailErrorNotSignedIn
}
return error.localizedDescription
}
private func deleteHighlight(_ highlight: Highlight) async {
highlights.removeAll { $0.id == highlight.id }
do {
try await api.deleteHighlight(id: highlight.id)
} catch {
// Re-Fetch nach Fehler (Optimistic-Update-Rollback).
await loadHighlights()
}
}
private func saveNote(for highlight: Highlight, newNote: String?) async {
do {
try await api.setHighlightNote(id: highlight.id, note: newNote)
if let idx = highlights.firstIndex(where: { $0.id == highlight.id }) {
highlights[idx].note = newNote
}
} catch {
// `loadState` rendert nur bei `article == nil` → wäre hier stiller
// Fehlschlag. Über `writeError` (gleicher Alert wie addHighlight)
// sieht der User, dass die Notiz nicht gespeichert wurde.
writeError = writeErrorMessage(error)
}
}
private func setColor(for highlight: Highlight, to color: HighlightColor) async {
do {
try await api.setHighlightColor(id: highlight.id, color: color)
if let idx = highlights.firstIndex(where: { $0.id == highlight.id }) {
highlights[idx].color = color
}
} catch {
// Siehe saveNote: sichtbarer Alert statt stummem loadState-Fehler.
writeError = writeErrorMessage(error)
}
}
/// Wiederverwendbares Tap-Target rund um den Farb-Strich — Tap öffnet
/// ein Menu mit den 4 Farben. Tap auf einen Eintrag setzt die Farbe.
private func colorMenu(
for highlight: Highlight,
@ViewBuilder label: () -> some View
) -> some View {
Menu {
ForEach(HighlightColor.allCases) { color in
Button {
Task { await setColor(for: highlight, to: color) }
} label: {
Label(color.label, systemImage: color == highlight.color ? "checkmark" : "")
}
}
} label: {
label()
}
}
private func addTag(_ tag: String) async {
let trimmed = tag.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
showTagSheet = false
do {
try await api.addTag(articleId: articleId, tag: trimmed)
// Optimistic — Article-State refresh aus dem event-sync
// (Reducer hat schon gefeuert beim emit, daher synchron lesbar).
await refreshArticleState()
} catch {
writeError = writeErrorMessage(error)
}
}
private func dismissWarning(for article: Article) async {
do {
try await api.dismissArticleWarning(articleId: article.id)
await refreshArticleState()
} catch {
writeError = writeErrorMessage(error)
}
}
private func removeTag(_ tag: String, from article: Article) async {
let normalized = tag.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let remaining = article.tags.filter { $0 != normalized }
do {
try await api.setArticleTags(articleId: article.id, tags: remaining)
await refreshArticleState()
} catch {
writeError = writeErrorMessage(error)
}
}
/// Liest die Article-Projection nach einem mutierenden Event neu
/// aus dem event-sync und ersetzt `self.article`. Optimistic-Path
/// fürs Tag-UI — Reducer hat schon synchron im emit gefeuert.
private func refreshArticleState() async {
do {
let updated = try await api.fetchArticle(id: articleId)
article = updated
} catch {
// Stale-Article ist tolerierbar — nächster Load holt's nach.
}
}
// MARK: - Inhalts-Extraktion
/// Versucht, den Artikel-Text nachzuladen (`enrichArticle`).
/// - `force == true` (Retry-Button): hebt die Session-Sperre auf und
/// ignoriert den Einmal-Guard.
/// - `force == false` (Auto-Versuch aus `load()`): nur eingeloggt und nur
/// einmal pro Artikel, damit eine dauerhaft leere/blockierte Quelle
/// keine Schleife auslöst.
private func extractContent(force: Bool) async {
guard let article, article.content.isEmpty,
let url = URL(string: article.originalUrl)
else { return }
if !force {
guard case .signedIn = auth.status else { return }
guard !autoExtractTried.contains(article.id) else { return }
}
autoExtractTried.insert(article.id)
if force { await api.clearEnrichmentFailure(articleId: article.id) }
isExtracting = true
extractionError = nil
do {
try await api.enrichArticle(articleId: article.id, url: url)
await refreshArticleState()
} catch {
extractionError = extractionErrorMessage(error)
}
isExtracting = false
}
/// Übersetzt einen Extraktions-Fehler in eine nutzersichtbare Meldung.
private func extractionErrorMessage(_ error: Error) -> String {
if let syncError = error as? PagetaSyncError {
switch syncError {
case .extractionEmpty:
return syncError.errorDescription ?? PagetaStrings.detailErrorNoText
case .notAuthenticated:
return PagetaStrings.detailErrorSignInToFetch
default:
return syncError.localizedDescription
}
}
if let authError = error as? AuthError, case .notSignedIn = authError {
return PagetaStrings.detailErrorSignInToFetch
}
// HTTP/Netz (z. B. 502 extraction-failed vom Server, Timeout).
return PagetaStrings.detailErrorFetchFailed
}
// MARK: - Lese-Fortschritt
/// Reagiert auf jede Scroll-Geometrie-Änderung. Zwei Phasen:
/// 1. **Restore** (einmalig): sobald das Layout eine echte `range` kennt,
/// auf die gespeicherte Position springen — danach nie wieder, sonst
/// kämpfen Restore und Tracking gegeneinander (gleiche Logik wie der
/// `restoredFor`-Guard im Web).
/// 2. **Tracking**: signifikante Änderungen (≥2 % oder am Ende) live in den
/// Balken spiegeln und debounced syncen.
private func handleScroll(fraction: Double, range: CGFloat) {
guard let article else { return }
if !didRestoreScroll {
// Erst springen, wenn die Range steht — sonst landet der Restore
// bei y=0 und überschreibt den gespeicherten Fortschritt mit ~0.
guard range > 0 else { return }
didRestoreScroll = true
let saved = article.readingProgress
if saved > 0.02 {
progress.fraction = saved
scrollPosition.scrollTo(y: saved * range)
return
}
}
// Bis ans Ende gescrollt → automatisch als gelesen markieren. Der
// „Gelesen"-Button läuft über die Fortschritts-Fraction sichtbar voll.
if fraction >= 0.98, article.status != .finished {
markFinishedFromScroll()
}
let significant = abs(fraction - progress.fraction) >= 0.02 || fraction >= 0.98
guard significant else { return }
progress.fraction = fraction
scheduleProgressFlush(fraction)
}
/// Setzt den Status optimistic auf `.finished`, wenn ans Ende gescrollt
/// wurde. Der synchrone Status-Set vor dem await blockt Re-Entry, falls
/// `handleScroll` im 0,98..1-Bereich mehrfach feuert (gleiche Logik wie
/// `autoFinish` im Web).
private func markFinishedFromScroll() {
guard article?.status != .finished else { return }
article?.status = .finished
if let idx = siblings.firstIndex(where: { $0.id == articleId }) {
siblings[idx].status = .finished
}
Task {
do {
try await api.updateArticle(id: articleId, status: .finished)
} catch {
// Sekundär — kein Alert; nächstes Scroll-Ende versucht es erneut.
}
}
}
/// Debounced Flush — 1,5 s nach der letzten Scroll-Bewegung, wie im Web.
/// Verhindert, dass jedes Scroll-Tick ein Event nach sync2.mana.how schickt.
private func scheduleProgressFlush(_ fraction: Double) {
progressFlushTask?.cancel()
progressFlushTask = Task {
try? await Task.sleep(for: .milliseconds(1500))
if Task.isCancelled { return }
await flushProgress(fraction)
}
}
/// Schreibt beim Verlassen den letzten Stand sofort (kein Debounce) —
/// Pendant zum `beforeunload`-Flush im Web.
private func flushProgressNow() {
progressFlushTask?.cancel()
let value = progress.fraction
guard let current = article, value != current.readingProgress else { return }
Task { await flushProgress(value) }
}
private func flushProgress(_ fraction: Double) async {
guard let id = article?.id else { return }
do {
try await api.updateArticle(id: id, readingProgress: fraction)
// Lokale Kopie nachziehen, damit flushProgressNow nicht doppelt sendet.
article?.readingProgress = fraction
} catch {
// Fortschritt ist sekundär — kein Alert; der nächste Scroll
// (oder das Verlassen) versucht es erneut.
}
}
}
/// Beobachtbarer Live-Lesefortschritt (0..1). Eigenes `@Observable`-Model
/// statt `@State Double` auf der Reader-View: so invalidiert eine Scroll-
/// Aktualisierung NUR die Views, die `fraction` lesen (Balken + Button) —
/// nicht den ganzen Reader-Body mit der teuren ArticleBodyTextView-Brücke.
/// Das war die Ursache des Scroll-Ruckelns.
@Observable final class ReadingProgressModel {
var fraction: Double = 0
}
/// Hält Scroll-Offset + Body-Position für die Scroll-zu-Treffer-Mathematik
/// der In-Artikel-Suche. Bewusst KEINE `@Observable`-Klasse: die häufigen
/// Scroll-Updates (jeder Tick setzt `scrollY`) sollen den teuren Reader-Body
/// NICHT invalidieren — gelesen wird nur imperativ in `focusMatch`.
final class ReaderScrollTracker {
/// Aktueller vertikaler Scroll-Offset (`contentOffset.y`).
var scrollY: CGFloat = 0
/// Body-Oberkante im `readerSpace` (= `bodyAbsoluteTop − scrollY`).
var bodyMinY: CGFloat = 0
}
/// Dünner Lese-Fortschritts-Balken oben über dem Reader. Liest `fraction`
/// selbst → beim Scrollen rendert nur diese kleine View neu.
private struct ReadingProgressBar: View {
let progress: ReadingProgressModel
var body: some View {
GeometryReader { geo in
Rectangle()
.fill(PagetaTheme.primary)
.frame(width: geo.size.width * progress.fraction, height: 2)
}
.frame(height: 2)
.allowsHitTesting(false)
}
}
/// „Gelesen" ↔ „Wieder lesen"-Button mit Fortschritts-Füllung. Liest
/// `fraction` selbst → Scroll-Updates rendern nur diesen Button neu, nicht
/// den Reader-Body. `isFinished`/`onToggle` reicht die Parent-View durch.
private struct ReadProgressButton: View {
let progress: ReadingProgressModel
let isFinished: Bool
let onToggle: () -> Void
var body: some View {
let fill = isFinished ? 1 : progress.fraction
Button(action: onToggle) {
Label(
isFinished ? PagetaStrings.detailReadAgain : PagetaStrings.statusFinished,
systemImage: isFinished ? "arrow.uturn.left.circle" : "checkmark.circle.fill"
)
.font(.callout.weight(.medium))
.foregroundStyle(isFinished ? Color.white : PagetaTheme.foreground)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(alignment: .leading) {
GeometryReader { geo in
Rectangle()
.fill(PagetaTheme.primary.opacity(isFinished ? 1 : 0.28))
.frame(width: geo.size.width * fill)
.animation(.easeOut(duration: 0.3), value: fill)
}
}
.background(PagetaTheme.surfaceHover)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(PagetaTheme.primary.opacity(0.5), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
/// Misst die intrinsische Größe der Inline-Palette, damit der Parent sie
/// mittig über der Auswahl platzieren kann.
private struct PaletteSizeKey: PreferenceKey {
static let defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
let next = nextValue()
if next != .zero { value = next }
}
}