pageta-native/Sources/Features/ArticleSummarySection.swift
Till JS d4a388b4ed fix(ui): π-11 Polish — Reader-Padding, Cookie-Wall-Banner, Lese-Liste-Sections, Feed-Reader
Bug-Fixes:
- ArticleDetailView: ScrollView.contentMargins(.bottom, 60) — die
  iOS-18+-floating Tab-Bar verdeckte sonst die letzten Zeilen
- Empty-State-Copy entstaubt (kein „kommt mit π-6" mehr)
- Consent-Wall-Warning: ArticleExtractPreview decodet jetzt
  `warning`, persistiert via ArticleSavedPayload + ArticleState,
  Reader rendert Banner mit „Original im Browser öffnen"-Link
  wenn warning == "probable_consent_wall"
- AsyncImage-Placeholder von paper-cream auf Color.secondary.opacity(0.15)
  — sah im Dark-Mode wie helles Quadrat aus
- ArticleStatus.reading.label: „Lese" → „Liest" (Imperativ → Verb)

Lese-Liste-Umbau (Tills Feedback):
- Statt 4-Tab-Picker jetzt ein Single-Scroll mit Sections:
  Lesend → Ungelesen → Gelesen. Sticky-Header via List/Section.
- Archiv ist eigene Seite (ArchivedListView), erreichbar via
  Toolbar-Icon rechts. Aus dem Haupt-Scroll raus, damit's nicht
  permanent stört.
- Restore-Action im Archiv (Swipe → Wiederherstellen → status=unread)

Feed-Polish:
- FeedItemRow ist jetzt tappbar — Tap pusht in FeedItemReaderView
  (rendert FeedItem.content direkt aus Pool ohne Save-Roundtrip).
  Toolbar-Action „In Lese-Liste speichern" macht Save explizit.
- Swipe-Action „In Lese-Liste" als Quick-Save direkt in der Row.

ArticleSummarySection: Empty-State jetzt kompakter Pill-Trigger
statt großer Card mit leerem Header. Volle Card entsteht erst nach
Generieren (oder bei Error/Loading).

Toolbar: ellipsis.circle → ellipsis (iOS 18+ rendert Toolbar-Items
in Glass-Capsules, .circle ergab doppelten Kreis).

iOS-Sim BUILD SUCCEEDED, 38/38 Tests grün.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:33:25 +02:00

127 lines
3.4 KiB
Swift

import ManaLLM
import SwiftUI
/// Cross-App-LLM-Pilot (π-AI-1): erzeugt eine kurze Artikel-Summary
/// via `ManaLLM.summarize(...)` — bevorzugt Foundation Models (iOS 26+
/// auf Apple-Intelligence-Geräten), fällt sonst auf Gemma E2B zurück
/// (falls bereits via Memoro im Shared-Container geladen) oder rendert
/// einen ehrlichen "nicht verfügbar"-State.
///
/// Eingehängt in `ArticleDetailView`. Eine eigene Sektion bewusst, weil:
/// - User soll kontrolliert anstoßen ("Zusammenfassen"), nicht
/// automatisch — Lokal-LLM-Calls kosten Akku/Compute.
/// - Result wird nicht in Article persistiert (Server-Wahrheit
/// bleibt). Re-Open = neuer Call. Caching kommt mit π-AI-2.
struct ArticleSummarySection: View {
let article: Article
@State private var summary: LLMSummary?
@State private var generating = false
@State private var error: String?
var body: some View {
// Empty-State ist kompakter Pill-Trigger — kein leerer Card-
// Block. Erst nach Generieren entsteht die volle Card.
if summary == nil, !generating, error == nil {
compactTrigger
} else {
fullCard
}
}
private var compactTrigger: some View {
Button {
Task { await generate() }
} label: {
HStack(spacing: 6) {
Image(systemName: "sparkles")
Text("Zusammenfassen")
.font(.subheadline.weight(.medium))
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
Capsule().fill(PagetaTheme.primary.opacity(0.12))
)
.foregroundStyle(PagetaTheme.primary)
.overlay(
Capsule().stroke(PagetaTheme.primary.opacity(0.35), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
private var fullCard: some View {
VStack(alignment: .leading, spacing: 12) {
header
content
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(PagetaTheme.surfaceHover)
)
}
private var header: some View {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.foregroundStyle(PagetaTheme.primary)
Text("KI-Zusammenfassung")
.font(.headline)
Spacer()
if !generating, summary != nil {
Button("Neu") { Task { await generate() } }
.font(.caption)
.buttonStyle(.borderless)
}
}
}
@ViewBuilder
private var content: some View {
if generating {
HStack(spacing: 10) {
ProgressView()
Text("Lokale KI denkt nach …")
.font(.caption)
.foregroundStyle(.secondary)
}
} else if let summary {
VStack(alignment: .leading, spacing: 8) {
Text(summary.headline)
.font(.subheadline.weight(.semibold))
Text(summary.intro)
.font(.body)
.foregroundStyle(.primary)
}
} else if let error {
VStack(alignment: .leading, spacing: 6) {
Label(error, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
Button("Erneut versuchen") { Task { await generate() } }
.font(.caption)
.buttonStyle(.borderless)
}
}
}
private func generate() async {
generating = true
error = nil
defer { generating = false }
let text = article.content
guard !text.isEmpty else {
error = "Artikel hat keinen Inhalt."
return
}
// Pageta-Kontext liefert mehr Material als Memoro-Audio —
// `summarize` per ManaLLM funktioniert beidseitig.
if let result = await ManaLLM.summarize(text, level: .fast) {
summary = result
} else {
error = "Lokale KI nicht verfügbar oder unzureichend."
}
}
}