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>
424 lines
12 KiB
Swift
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")
|
|
}
|
|
}
|