pageta-native/Sources/Features/RueckblickTabView.swift
Till JS 8d6fc1a666 i18n: pageta-native voll über PagetaStrings (Phase 4)
Alle UI-Strings der Feature-/App-Schicht ziehen aus mana/i18n
(Listen/Detail/Marker/Feed/Rückblick/Zusammenfassen/Settings/Account
+ Archiv/Bulk/Note-Sheet/Palette); Interpolation als generierte Funktionen.
Verwaisten-Check grün (0 hartkodierte UI-Strings). Onboarding bleibt
seine eigene SOT (mana/content/onboarding).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:06:55 +02:00

424 lines
12 KiB
Swift

import ManaAuthUI
import ManaCore
import ManaLLM
import SwiftUI
/// Lese-Rückblick: synthetisiert, was die Person WIRKLICH gelesen hat
/// (fertig oder substanziell gescrollt) — Tag/Woche/Gesamt. Pendant zur
/// Web-Route `/rueckblick`.
///
/// Architektur wie im Web: ein **deterministisches Gerüst** (Kennzahlen,
/// Themen, eigene Markierungen) steht IMMER — gratis, offline, ohne LLM.
/// Darüber eine **optionale Synthese** via `ManaLLM.generate` (lokal-first;
/// liefert sie nichts, bleibt das Gerüst nutzbar). Reine Auswahl-/Gruppier-/
/// Prompt-Logik in `Digest` (testbar, geteilt mit der Web-Konvention).
///
/// Motivations-Leitplanke (Wert 8): ruhiger Spiegel, keine Streaks/Belohnung.
struct RueckblickTabView: View {
/// Profil + Tap-Handler für den Konto-Avatar oben rechts (von
/// ``RootView`` gereicht). Nur iOS — auf macOS liegt das Konto in der
/// Settings-Scene (⌘,). Defaults für den macOS-Sidebar-Aufruf.
var accountProfile: ProfileInfo?
var onAccountTap: () -> Void = {}
@Environment(AuthClient.self) private var auth
@Environment(PendingShareSignal.self) private var shareSignal
private var api: PagetaAPI {
PagetaAPI(auth: auth)
}
@State private var articles: [Article] = []
@State private var allHighlights: [HighlightWithArticle] = []
@State private var loadState: LoadState = .idle
@State private var scope: DigestScope = .week
/// Fenster-Bezugspunkt einmal pro Load festhalten.
@State private var loadedAt = Date(timeIntervalSince1970: 0)
@State private var synthesizing = false
@State private var synthesis: String?
@State private var synthesisError: String?
@State private var synthesisInfo: String?
enum LoadState: Equatable {
case idle
case loading
case loaded
case error(String)
}
// MARK: - Abgeleitetes Gerüst (rein, gratis)
private var selected: [Article] {
Digest.selectArticles(articles, scope: scope, now: loadedAt)
}
private func briefText(_ a: Article) -> String {
if let ex = a.excerpt?.trimmingCharacters(in: .whitespacesAndNewlines), !ex.isEmpty {
return ex
}
return String(a.content.prefix(280))
}
private var briefs: [DigestBrief] {
selected.map { a in
DigestBrief(
id: a.id,
title: a.displayTitle,
source: a.siteName,
url: a.id,
brief: briefText(a),
tags: a.tags
)
}
}
private var themeGroups: [ThemeGroup] {
Digest.groupByTheme(briefs)
}
private var highlightLines: [DigestHighlightLine] {
let ids = Set(selected.map(\.id))
return allHighlights
.filter { ids.contains($0.article.id) }
.map {
DigestHighlightLine(
articleId: $0.article.id,
source: $0.article.siteName,
title: $0.article.displayTitle,
text: $0.highlight.text,
note: $0.highlight.note
)
}
}
private var highlightsByArticle: [(
title: String,
source: String?,
lines: [DigestHighlightLine]
)] {
var order: [String] = []
var map: [String: (title: String, source: String?, lines: [DigestHighlightLine])] = [:]
for h in highlightLines {
if map[h.articleId] == nil {
order.append(h.articleId)
map[h.articleId] = (h.title, h.source, [])
}
map[h.articleId]?.lines.append(h)
}
return order.compactMap { map[$0] }
}
private var totalMinutes: Int {
selected.reduce(0) { $0 + ($1.readingTimeMinutes ?? 0) }
}
private var sourceCount: Int {
Set(selected.compactMap { $0.siteName?.lowercased() }.filter { !$0.isEmpty }).count
}
// MARK: - Body
var body: some View {
NavigationStack {
Group {
switch loadState {
case .idle, .loading:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case let .error(message):
ContentUnavailableView(
PagetaStrings.reflectionUnavailable,
systemImage: "exclamationmark.triangle",
description: Text(message)
)
case .loaded:
contentScroll
}
}
.navigationTitle(PagetaStrings.tabReflection)
#if os(iOS)
.manaAccountToolbar(auth: auth, profile: accountProfile, onTap: onAccountTap)
#endif
.navigationDestination(for: String.self) { articleId in
ArticleDetailView(articleId: articleId)
}
.paperBackground()
.task {
if loadState == .idle { await load() }
}
.onAppear {
if loadState != .idle, loadState != .loading {
Task { await load() }
}
}
.refreshable { await load() }
.onChange(of: shareSignal.flushedTick) { _, _ in
Task { await load() }
}
.onChange(of: scope) { _, _ in resetSynthesis() }
}
}
private var contentScroll: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text(PagetaStrings.reflectionIntro)
.font(.subheadline)
.foregroundStyle(.secondary)
Picker("Zeitraum", selection: $scope) {
ForEach(DigestScope.allCases) { s in
Text(s.label).tag(s)
}
}
.pickerStyle(.segmented)
if selected.isEmpty {
ContentUnavailableView(
PagetaStrings.reflectionEmptyTitle,
systemImage: "book.closed",
description: Text(
PagetaStrings.reflectionEmptyBody
)
)
.padding(.top, 24)
} else {
statsRow
synthesisCard
themesSection
if !highlightsByArticle.isEmpty { highlightsSection }
}
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
.macReadingWidth()
}
.toolbar {
if !selected.isEmpty {
ShareLink(item: buildMarkdown()) {
Image(systemName: "square.and.arrow.up")
}
}
}
}
private var statsRow: some View {
HStack(spacing: 18) {
stat("\(selected.count)", PagetaStrings.tabArticles)
stat("\(totalMinutes)", "Min")
stat("\(sourceCount)", PagetaStrings.reflectionSources)
if !highlightLines.isEmpty {
stat("\(highlightLines.count)", PagetaStrings.tabHighlights)
}
}
}
private func stat(_ value: String, _ label: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(value).font(.title3.weight(.semibold)).foregroundStyle(PagetaTheme.foreground)
Text(label).font(.caption).foregroundStyle(.secondary)
}
}
private var synthesisCard: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(PagetaStrings.reflectionSynthesis)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer()
Button {
Task { await synthesize() }
} label: {
Label(
synthesizing ? PagetaStrings.reflectionGenerating :
(synthesis == nil ? PagetaStrings.reflectionSynthesize : PagetaStrings.reflectionRegenerate),
systemImage: "sparkles"
)
.font(.callout.weight(.medium))
}
.buttonStyle(.borderedProminent)
.tint(PagetaTheme.primary)
.disabled(synthesizing)
}
if synthesizing {
Text(PagetaStrings.reflectionLocalHint)
.font(.callout)
.foregroundStyle(.secondary)
} else if let synthesisError {
Text(synthesisError)
.font(.callout)
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
} else if let synthesis {
Text(synthesis)
.font(.callout)
.foregroundStyle(PagetaTheme.foreground)
.fixedSize(horizontal: false, vertical: true)
if let synthesisInfo {
Text(synthesisInfo).font(.caption2).foregroundStyle(.secondary)
}
} else {
Text(
PagetaStrings.reflectionSynthesisHint
)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(PagetaTheme.surfaceHover, in: RoundedRectangle(cornerRadius: 12))
}
private var themesSection: some View {
VStack(alignment: .leading, spacing: 14) {
Text(PagetaStrings.reflectionThemes)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
ForEach(themeGroups) { group in
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text(group.theme).font(.headline)
Text("· \(group.articles.count)").font(.subheadline)
.foregroundStyle(.secondary)
}
ForEach(group.articles) { brief in
NavigationLink(value: brief.url) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(brief.title)
.font(.callout)
.foregroundStyle(PagetaTheme.foreground)
.multilineTextAlignment(.leading)
if let source = brief.source {
Text("· \(source)").font(.caption).foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
}
.buttonStyle(.plain)
}
}
}
}
}
private var highlightsSection: some View {
VStack(alignment: .leading, spacing: 14) {
Text(PagetaStrings.reflectionYourHighlights)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
ForEach(Array(highlightsByArticle.enumerated()), id: \.offset) { _, group in
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Text(group.title).font(.subheadline.weight(.medium))
if let source = group.source {
Text("· \(source)").font(.caption).foregroundStyle(.secondary)
}
}
ForEach(Array(group.lines.enumerated()), id: \.offset) { _, line in
VStack(alignment: .leading, spacing: 2) {
Text(line.text)
.font(.callout)
.italic()
.foregroundStyle(PagetaTheme.foreground)
if let note = line.note, !note.isEmpty {
Text("— \(note)").font(.caption).foregroundStyle(.secondary)
}
}
.padding(.leading, 10)
.overlay(alignment: .leading) {
Rectangle().fill(PagetaTheme.primary).frame(width: 2)
}
}
}
}
}
}
// MARK: - Aktionen
private func resetSynthesis() {
synthesis = nil
synthesisError = nil
synthesisInfo = nil
}
private func load() async {
loadState = .loading
resetSynthesis()
do {
async let a = api.fetchArticles(limit: 500)
async let h = api.fetchAllHighlights()
articles = try await a
allHighlights = try await h
loadedAt = Date()
loadState = .loaded
} catch {
loadState = .error(error.localizedDescription)
}
}
private func synthesize() async {
let currentBriefs = briefs
let currentHighlights = highlightLines
guard !currentBriefs.isEmpty, !synthesizing else { return }
synthesizing = true
resetSynthesis()
let p = Digest.buildSynthesisPrompt(
scope: scope,
briefs: currentBriefs,
highlights: currentHighlights
)
let result = await ManaLLM.generate(
prompt: p.prompt,
instructions: p.instructions,
level: .creative,
maxTokens: 700
)
let clean = result?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if clean.count >= 20 {
synthesis = clean
var parts: [String] = []
if p.usedArticles < currentBriefs.count {
parts.append(PagetaStrings.reflectionPartArticles(p.usedArticles, currentBriefs.count))
}
if p.usedHighlights < currentHighlights.count {
parts.append(PagetaStrings.reflectionPartHighlights(p.usedHighlights, currentHighlights.count))
}
synthesisInfo =
parts.isEmpty ? nil : PagetaStrings.reflectionSynthesisShort(parts.joined(separator: " und "))
} else {
synthesisError =
PagetaStrings.reflectionAiNoSynthesis
}
synthesizing = false
}
private func buildMarkdown() -> String {
var lines: [String] = [PagetaStrings.reflectionExportTitle(scope.label), ""]
lines.append(PagetaStrings.reflectionExportStats(selected.count, totalMinutes, sourceCount))
lines.append("")
if let synthesis { lines.append(synthesis); lines.append("") }
lines.append(PagetaStrings.reflectionExportReadSection(selected.count))
for a in selected {
let src = a.siteName.map { " — \($0)" } ?? ""
lines.append("- [\(a.displayTitle)](\(a.originalUrl))\(src)")
}
if !highlightsByArticle.isEmpty {
lines.append("")
lines.append(PagetaStrings.reflectionExportHighlightsSection)
for group in highlightsByArticle {
lines.append("")
lines.append("**\(group.title)**")
for line in group.lines {
lines.append("> \(line.text)")
if let note = line.note, !note.isEmpty { lines.append("> — \(note)") }
}
}
}
return lines.joined(separator: "\n")
}
}