Files
denshooter 5bc81d5b3b Initial commit: nightly iOS app + Supabase backend
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>
2026-04-23 23:31:38 +02:00

288 lines
9.9 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 25 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()
}
}