pageta-native/Sources/Features/MarkierungenTabView.swift
Till JS d4c0e11d5b Tabs umbenannt: Lese-Liste → Stapel, Markierungen → Marker
User-facing Strings konsistent umgestellt: Tab-Labels + Nav-Titel +
macOS-Sidebar, leere Zustände, Such-Prompt, Feed-Speichern-Aktionen
("Auf den Stapel"), Share-Toast ("Zum Stapel hinzugefügt"), App-Shortcut-
Beschreibung, Konto-Prosa, Marker-Bezüge im Reader (Notiz-Sheet, Zähler,
Löschen-Label, Kopieren). Verb "markieren" + Lese-Status bleiben.
Swift-Typnamen (LeseListeTabView/MarkierungenTabView) unverändert (interne
Identifier). Build grün.

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

326 lines
8.9 KiB
Swift

import ManaAuthUI
import ManaCore
import SwiftUI
/// Globale Sammlung aller Highlights über alle Artikel hinweg.
///
/// **Layout-Entscheidung 2026-05-21:** flat sortiert nach `createdAt`
/// desc (jüngste zuerst). Keine Gruppierung by Article — Till will
/// "was hab ich zuletzt markiert" als primärer Use-Case. Artikel-
/// Kontext (Titel, Site) steht pro Row als Untertitel.
///
/// Tap auf eine Row navigiert in die `ArticleDetailView` des
/// zugehörigen Artikels. Swipe-Delete entfernt nur den Highlight,
/// nicht den Artikel.
struct MarkierungenTabView: View {
@Environment(AuthClient.self) private var auth
@Environment(PendingShareSignal.self) private var shareSignal
private var api: PagetaAPI { PagetaAPI(auth: auth) }
@State private var items: [HighlightWithArticle] = []
@State private var loadState: LoadState = .idle
@State private var editingNoteFor: HighlightWithArticle?
@State private var writeError: String?
enum LoadState: Equatable {
case idle
case loading
case loaded
case error(String)
}
var body: some View {
NavigationStack {
Group {
if items.isEmpty {
emptyOrLoadingView
} else {
listView
}
}
.navigationTitle("Marker")
.navigationDestination(for: String.self) { articleId in
ArticleDetailView(articleId: articleId)
}
.alert(
"Aktion fehlgeschlagen",
isPresented: Binding(
get: { writeError != nil },
set: { if !$0 { writeError = nil } }
),
presenting: writeError
) { _ in
Button("OK", role: .cancel) { writeError = nil }
} message: { msg in
Text(msg)
}
.paperBackground()
.task {
if items.isEmpty { await load() }
}
.onAppear {
// Reload bei Re-Appear (gleiche Logik wie LeseListe) —
// fängt z.B. Highlight-Löschungen aus dem Detail-View ab.
if !items.isEmpty {
Task { await load() }
}
}
.refreshable { await load() }
.onChange(of: shareSignal.flushedTick) { _, _ in
// Wenn ein frisch gespeicherter Artikel via ShareExt
// reinkommt + der User direkt aus dem Web Highlights
// gesetzt hat (Cross-Device-Sync), kann sich die Liste
// ändern. Billiger Re-Load.
Task { await load() }
}
}
}
@ViewBuilder
private var emptyOrLoadingView: some View {
switch loadState {
case .idle, .loading:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case let .error(message):
ContentUnavailableView(
"Marker konnten nicht geladen werden",
systemImage: "exclamationmark.triangle",
description: Text(message)
)
case .loaded:
ContentUnavailableView(
"Noch nichts markiert",
systemImage: "highlighter",
description: Text("Markiere Textstellen in einem Artikel — sie erscheinen hier zusammen.")
)
}
}
private var listView: some View {
List {
ForEach(items) { item in
NavigationLink(value: item.highlight.articleId) {
MarkierungRow(
item: item,
onColorChange: { color in
Task { await setColor(for: item.highlight, to: color) }
}
)
}
.listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
.swipeActions(edge: .leading) {
Button {
editingNoteFor = item
} label: {
Label(item.highlight.note == nil ? "Notiz" : "Notiz bearbeiten", systemImage: "note.text")
}
.tint(.indigo)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
Task { await delete(item.highlight) }
} label: {
Label("Löschen", systemImage: "trash")
}
}
.contextMenu {
highlightContextMenu(item)
}
}
}
.listStyle(.plain)
.macReadingWidth()
.sheet(item: $editingNoteFor) { item in
HighlightNoteSheet(
highlightText: item.highlight.text,
initialNote: item.highlight.note,
color: item.highlight.color,
onSave: { newNote in
await saveNote(for: item.highlight, newNote: newNote)
}
)
}
}
/// Long-Press-Kontextmenü für eine Markierung. Spiegelt die Swipe-
/// Aktionen (Notiz, Löschen) und ergänzt sie um Kopieren, Farbe und
/// Teilen — der Tap navigiert weiterhin in den Artikel.
@ViewBuilder
private func highlightContextMenu(_ item: HighlightWithArticle) -> some View {
Button {
PlatformClipboard.copy(item.highlight.text)
} label: {
Label("Marker kopieren", systemImage: "doc.on.doc")
}
Button {
editingNoteFor = item
} label: {
Label(
item.highlight.note == nil ? "Notiz hinzufügen" : "Notiz bearbeiten",
systemImage: "note.text"
)
}
Menu {
ForEach(HighlightColor.allCases) { color in
Button {
Task { await setColor(for: item.highlight, to: color) }
} label: {
Label(
color.label,
systemImage: color == item.highlight.color ? "checkmark" : "circle.fill"
)
}
}
} label: {
Label("Farbe", systemImage: "paintpalette")
}
ShareLink(item: highlightShareText(item)) {
Label("Teilen …", systemImage: "square.and.arrow.up")
}
Divider()
Button(role: .destructive) {
Task { await delete(item.highlight) }
} label: {
Label("Löschen", systemImage: "trash")
}
}
/// Markierungs-Text mit Quellen-Attribution für Teilen.
private func highlightShareText(_ item: HighlightWithArticle) -> String {
var out = "\u{201C}\(item.highlight.text)\u{201D}"
if let note = item.highlight.note, !note.isEmpty {
out += "\n\n\(note)"
}
out += "\n— \(item.article.title)"
return out
}
private func load() async {
loadState = .loading
do {
items = try await api.fetchAllHighlights()
loadState = .loaded
} catch {
loadState = .error(error.localizedDescription)
}
}
private func delete(_ highlight: Highlight) async {
do {
try await api.deleteHighlight(id: highlight.id)
items.removeAll { $0.highlight.id == highlight.id }
} catch {
writeError = error.localizedDescription
}
}
private func saveNote(for highlight: Highlight, newNote: String?) async {
do {
try await api.setHighlightNote(id: highlight.id, note: newNote)
// Lokal patchen — der Pull holt es später ein, aber UI muss sofort
// reagieren.
if let idx = items.firstIndex(where: { $0.highlight.id == highlight.id }) {
items[idx].highlight.note = newNote
}
} catch {
writeError = error.localizedDescription
}
}
private func setColor(for highlight: Highlight, to color: HighlightColor) async {
do {
try await api.setHighlightColor(id: highlight.id, color: color)
if let idx = items.firstIndex(where: { $0.highlight.id == highlight.id }) {
items[idx].highlight.color = color
}
} catch {
writeError = error.localizedDescription
}
}
}
/// Eine Markierung in der Liste — Farb-Strich links, Highlight-Text,
/// Artikel-Kontext + Datum als Untertitel.
struct MarkierungRow: View {
let item: HighlightWithArticle
let onColorChange: (HighlightColor) -> Void
private var subtitle: String {
var parts: [String] = []
parts.append(item.article.title)
if let site = item.article.siteName, !site.isEmpty {
parts.append(site)
}
if let date = formattedDate(item.highlight.createdAt) {
parts.append(date)
}
return parts.joined(separator: " · ")
}
var body: some View {
HStack(alignment: .top, spacing: 10) {
Menu {
ForEach(HighlightColor.allCases) { color in
Button {
onColorChange(color)
} label: {
Label(color.label, systemImage: color == item.highlight.color ? "checkmark" : "")
}
}
} label: {
RoundedRectangle(cornerRadius: 2)
.fill(item.highlight.color.swiftUIColor)
.frame(width: 4)
}
.buttonStyle(.plain)
.menuStyle(.borderlessButton)
VStack(alignment: .leading, spacing: 4) {
Text(item.highlight.text)
.font(.callout)
.foregroundStyle(PagetaTheme.foreground)
.lineLimit(4)
if let note = item.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))
.lineLimit(3)
}
.padding(.top, 2)
}
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.padding(.vertical, 4)
}
/// `createdAt` ist ISO8601 vom Server. Auf "vor 3 Stunden" /
/// "21.05.2026"-Format mappen.
private func formattedDate(_ iso: String) -> String? {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var date = formatter.date(from: iso)
if date == nil {
formatter.formatOptions = [.withInternetDateTime]
date = formatter.date(from: iso)
}
guard let date else { return nil }
let relative = RelativeDateTimeFormatter()
relative.unitsStyle = .short
// Über 7 Tage absolut anzeigen — "vor 3 Wochen" hilft niemandem.
if abs(date.timeIntervalSinceNow) > 7 * 24 * 3600 {
let abs = DateFormatter()
abs.dateStyle = .medium
abs.timeStyle = .none
return abs.string(from: date)
}
return relative.localizedString(for: date, relativeTo: Date())
}
}