5bc81d5b3b
iOS SwiftUI app with Supabase auth/realtime, Node.js backend, Docker/Supabase self-hosted infrastructure, and APNs scheduler. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
288 lines
9.9 KiB
Swift
288 lines
9.9 KiB
Swift
import SwiftUI
|
||
|
||
/// Persönliches Tagebuch — alle eigenen Posts, auch die anonymen.
|
||
/// Bleibt für immer. Das ist der Retention-Hook.
|
||
struct DiaryView: View {
|
||
@StateObject private var viewModel = DiaryViewModel()
|
||
@EnvironmentObject var appState: AppState
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
ZStack {
|
||
Color.nightBase.ignoresSafeArea()
|
||
|
||
if viewModel.groupedPosts.isEmpty && !viewModel.isLoading {
|
||
DiaryEmptyView()
|
||
} else {
|
||
ScrollView {
|
||
LazyVStack(alignment: .leading, spacing: 0) {
|
||
// "Vor genau einem Jahr" — Memory
|
||
if let memory = viewModel.yearAgoPost {
|
||
MemoryBanner(post: memory)
|
||
.padding(.bottom, 4)
|
||
}
|
||
|
||
ForEach(viewModel.groupedPosts, id: \.nightLabel) { group in
|
||
// Nacht-Header
|
||
DiaryNightHeader(label: group.nightLabel, count: group.posts.count)
|
||
|
||
ForEach(group.posts) { post in
|
||
DiaryPostRow(post: post) {
|
||
Task { await viewModel.deletePost(post) }
|
||
}
|
||
Divider().background(Color.nightBorder).padding(.leading, 16)
|
||
}
|
||
}
|
||
Color.clear.frame(height: 100)
|
||
}
|
||
}
|
||
.refreshable { await viewModel.load() }
|
||
}
|
||
}
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .principal) {
|
||
HStack(spacing: 7) {
|
||
Image(systemName: "book.closed.fill")
|
||
.foregroundColor(.nightPurple)
|
||
Text("tagebuch")
|
||
.font(.nightTitle(17))
|
||
.foregroundColor(.nightPrimary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.task { await viewModel.load() }
|
||
}
|
||
}
|
||
|
||
// MARK: - Memory Banner
|
||
|
||
struct MemoryBanner: View {
|
||
let post: Post
|
||
@State private var show = true
|
||
|
||
var body: some View {
|
||
if show {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "clock.arrow.circlepath")
|
||
.foregroundColor(.nightPurpleSoft)
|
||
.font(.system(size: 14))
|
||
Text("vor genau einem jahr")
|
||
.font(.nightLabel(12, weight: .semibold))
|
||
.foregroundColor(.nightPurpleSoft)
|
||
.kerning(0.5)
|
||
Spacer()
|
||
Button { withAnimation { show = false } } label: {
|
||
Image(systemName: "xmark")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.nightTertiary)
|
||
}
|
||
}
|
||
|
||
Text(post.content)
|
||
.font(.nightBody(15))
|
||
.foregroundColor(.nightPrimary.opacity(0.85))
|
||
.lineSpacing(4)
|
||
|
||
HStack(spacing: 5) {
|
||
if let mood = post.mood {
|
||
Text(mood.emoji).font(.nightMono(11))
|
||
Text(mood.label).font(.nightLabel(11))
|
||
}
|
||
Text("·")
|
||
Text(post.formattedTime)
|
||
.font(.nightMono(11))
|
||
}
|
||
.foregroundColor(.nightTertiary)
|
||
}
|
||
.padding(16)
|
||
.background(
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 0)
|
||
.fill(Color.nightPurple.opacity(0.06))
|
||
RoundedRectangle(cornerRadius: 0)
|
||
.fill(
|
||
LinearGradient(
|
||
colors: [Color.nightPurple.opacity(0.08), .clear],
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
)
|
||
)
|
||
}
|
||
)
|
||
.overlay(
|
||
Rectangle().fill(Color.nightBorder).frame(height: 1),
|
||
alignment: .bottom
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Night Group Header
|
||
|
||
struct DiaryNightHeader: View {
|
||
let label: String
|
||
let count: Int
|
||
|
||
var body: some View {
|
||
HStack {
|
||
Text(label)
|
||
.font(.nightLabel(12, weight: .semibold))
|
||
.foregroundColor(.nightSecondary)
|
||
.kerning(0.5)
|
||
Text("· \(count) Gedanke\(count == 1 ? "" : "n")")
|
||
.font(.nightLabel(12))
|
||
.foregroundColor(.nightTertiary)
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 10)
|
||
.background(Color.nightSurface)
|
||
}
|
||
}
|
||
|
||
// MARK: - Diary Post Row
|
||
|
||
struct DiaryPostRow: View {
|
||
let post: Post
|
||
let onDelete: () -> Void
|
||
@State private var confirmDelete = false
|
||
|
||
var body: some View {
|
||
HStack(alignment: .top, spacing: 0) {
|
||
Rectangle()
|
||
.fill(post.mood?.color ?? Color.nightTertiary)
|
||
.frame(width: 2)
|
||
.padding(.vertical, 14)
|
||
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
Text(post.formattedTime)
|
||
.font(.nightMono(12))
|
||
.foregroundColor(.nightTertiary)
|
||
|
||
if post.isAnonymous {
|
||
Text("· anonym")
|
||
.font(.nightLabel(11))
|
||
.foregroundColor(.nightTertiary)
|
||
.italic()
|
||
}
|
||
|
||
if let mood = post.mood {
|
||
Text("· \(mood.emoji) \(mood.label)")
|
||
.font(.nightLabel(11))
|
||
.foregroundColor(mood.color.opacity(0.7))
|
||
}
|
||
|
||
Spacer()
|
||
|
||
if post.resonanceCount > 0 {
|
||
HStack(spacing: 3) {
|
||
Image(systemName: "heart.fill")
|
||
.font(.system(size: 10))
|
||
Text("\(post.resonanceCount)")
|
||
.font(.nightLabel(11))
|
||
}
|
||
.foregroundColor(.nightRed.opacity(0.7))
|
||
}
|
||
}
|
||
|
||
Text(post.content)
|
||
.font(.nightBody(16))
|
||
.foregroundColor(.nightPrimary.opacity(0.88))
|
||
.lineSpacing(5)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 14)
|
||
.contextMenu {
|
||
Button(role: .destructive) {
|
||
confirmDelete = true
|
||
} label: {
|
||
Label("Löschen", systemImage: "trash")
|
||
}
|
||
}
|
||
}
|
||
.confirmationDialog(
|
||
"Post löschen?",
|
||
isPresented: $confirmDelete,
|
||
titleVisibility: .visible
|
||
) {
|
||
Button("Löschen", role: .destructive) { onDelete() }
|
||
} message: {
|
||
Text("Der Post wird aus dem Feed entfernt, bleibt aber in deinem Tagebuch-Archiv.")
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Empty State
|
||
|
||
struct DiaryEmptyView: View {
|
||
var body: some View {
|
||
VStack(spacing: 18) {
|
||
Image(systemName: "book.closed")
|
||
.font(.system(size: 48))
|
||
.foregroundColor(.nightTertiary)
|
||
Text("noch nichts hier")
|
||
.font(.nightTitle(18))
|
||
.foregroundColor(.nightPrimary)
|
||
Text("Deine Posts landen hier.\nIn einem Jahr kannst du nachlesen, was dich\nmitten in der Nacht beschäftigt hat.")
|
||
.font(.nightBody(15))
|
||
.foregroundColor(.nightSecondary)
|
||
.multilineTextAlignment(.center)
|
||
.lineSpacing(4)
|
||
}
|
||
.padding(40)
|
||
}
|
||
}
|
||
|
||
// MARK: - ViewModel
|
||
|
||
@MainActor
|
||
class DiaryViewModel: ObservableObject {
|
||
struct NightGroup { let nightLabel: String; let posts: [Post] }
|
||
|
||
@Published var groupedPosts: [NightGroup] = []
|
||
@Published var yearAgoPost: Post? = nil
|
||
@Published var isLoading = false
|
||
|
||
func load() async {
|
||
isLoading = true
|
||
defer { isLoading = false }
|
||
|
||
do {
|
||
let posts = try await supabase.getDiary()
|
||
|
||
// Gruppiere nach Nacht (Datum - 2h, damit 2–5 Uhr zur selben Nacht gehört)
|
||
let grouped = Dictionary(grouping: posts) { post -> String in
|
||
let nightDate = post.createdAt.addingTimeInterval(-2 * 3600)
|
||
let f = DateFormatter()
|
||
f.locale = Locale(identifier: "de_DE")
|
||
f.dateFormat = "EEEE, d. MMMM yyyy"
|
||
return f.string(from: nightDate)
|
||
}
|
||
|
||
groupedPosts = grouped
|
||
.sorted { $0.key > $1.key }
|
||
.map { NightGroup(nightLabel: $0.key, posts: $0.value) }
|
||
|
||
// Memory: Post von vor genau einem Jahr
|
||
let oneYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: Date())!
|
||
yearAgoPost = posts.first { post in
|
||
Calendar.current.isDate(post.createdAt, equalTo: oneYearAgo, toGranularity: .day)
|
||
}
|
||
} catch {
|
||
#if DEBUG
|
||
groupedPosts = [NightGroup(nightLabel: "gestern", posts: Post.previews)]
|
||
#endif
|
||
}
|
||
}
|
||
|
||
func deletePost(_ post: Post) async {
|
||
try? await supabase.softDeletePost(id: post.id)
|
||
await load()
|
||
}
|
||
}
|