Files
thoughts/ios/NightThoughts/Views/PostRowView.swift
T
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

292 lines
10 KiB
Swift

import SwiftUI
// MARK: - Post Row
struct PostRowView: View {
let post: Post
let onResonate: () -> Void
var onReport: (() -> Void)? = nil
@State private var showReport = false
var body: some View {
HStack(alignment: .top, spacing: 0) {
// Mood accent bar der einzige echte Farbakzent im Feed
Rectangle()
.fill(post.mood?.color ?? Color.nightTertiary)
.frame(width: 2)
.padding(.vertical, 18)
VStack(alignment: .leading, spacing: 11) {
// Author
HStack(spacing: 9) {
if post.isAnonymous {
AnonymousAvatar(size: 32)
} else {
AvatarView(user: post.author, size: 32)
}
VStack(alignment: .leading, spacing: 1) {
if post.isAnonymous {
Text("anonym")
.font(.nightLabel(13))
.foregroundColor(.nightSecondary)
.italic()
} else {
Text(post.author.displayName)
.font(.nightLabel(14, weight: .semibold))
.foregroundColor(.nightPrimary)
}
}
Spacer()
HStack(spacing: 8) {
if let mood = post.mood {
Text(mood.emoji)
.font(.nightMono(11))
.foregroundColor(mood.color.opacity(0.8))
}
Text(post.formattedTime)
.font(.nightMono(11))
.foregroundColor(.nightTertiary)
// Drei-Punkte-Menü für Report
Menu {
Button(role: .destructive) {
showReport = true
} label: {
Label("Melden", systemImage: "flag")
}
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 13))
.foregroundColor(.nightTertiary)
.padding(4)
}
}
}
// Content
Text(post.content)
.font(.nightBody(16))
.foregroundColor(.nightPrimary.opacity(0.9))
.lineSpacing(5)
.fixedSize(horizontal: false, vertical: true)
// Resonance
ResonanceButton(
count: post.resonanceCount,
isActive: post.hasResonated,
action: onResonate
)
}
.padding(.leading, 14)
.padding(.trailing, 16)
.padding(.vertical, 16)
}
.sheet(isPresented: $showReport) {
ReportSheet(postId: post.id)
}
}
}
// MARK: - Resonance Button
struct ResonanceButton: View {
let count: Int
let isActive: Bool
let action: () -> Void
@State private var scale: CGFloat = 1.0
var body: some View {
Button {
withAnimation(.spring(duration: 0.25, bounce: 0.7)) { scale = 1.4 }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation(.spring(duration: 0.2)) { scale = 1.0 }
}
action()
} label: {
HStack(spacing: 5) {
Image(systemName: isActive ? "heart.fill" : "heart")
.font(.system(size: 14))
.foregroundColor(isActive ? .nightRed : .nightSecondary)
.scaleEffect(scale)
Text(count > 0 ? "\(count)" : "hat mich getroffen")
.font(.nightLabel(13))
.foregroundColor(isActive ? .nightRed : .nightSecondary)
}
.padding(.vertical, 5)
.padding(.horizontal, count > 0 || isActive ? 10 : 0)
.background(
Capsule()
.fill(isActive ? Color.nightRed.opacity(0.1) : Color.clear)
)
}
.animation(.easeInOut(duration: 0.2), value: isActive)
}
}
// MARK: - Avatar
struct AvatarView: View {
let user: User
let size: CGFloat
var body: some View {
Group {
if let url = user.avatarURL {
AsyncImage(url: url) { img in img.resizable().scaledToFill() }
placeholder: { initials }
} else { initials }
}
.frame(width: size, height: size)
.clipShape(Circle())
}
var initials: some View {
ZStack {
Circle().fill(Color.nightPurple.opacity(0.18))
Text(String(user.displayName.prefix(1)).uppercased())
.font(.system(size: size * 0.38, weight: .semibold))
.foregroundColor(.nightPurpleSoft)
}
}
}
struct AnonymousAvatar: View {
let size: CGFloat
var body: some View {
ZStack {
Circle().fill(Color.nightRaised)
Image(systemName: "questionmark")
.font(.system(size: size * 0.35, weight: .semibold))
.foregroundColor(.nightSecondary)
}
.frame(width: size, height: size)
}
}
// MARK: - Report Sheet
struct ReportSheet: View {
let postId: String
@Environment(\.dismiss) var dismiss
@State private var selected: ReportReason? = nil
@State private var submitted = false
@State private var isLoading = false
enum ReportReason: String, CaseIterable {
case hate = "Hassrede / Diskriminierung"
case harassment = "Belästigung / Mobbing"
case selfharm = "Selbstverletzung / Suizid"
case illegal = "Illegale Inhalte"
case spam = "Spam"
case other = "Sonstiges"
}
var body: some View {
NavigationStack {
ZStack {
Color.nightSurface.ignoresSafeArea()
if submitted {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 48))
.foregroundColor(.nightGreen)
Text("Danke für deine Meldung")
.font(.nightTitle(18))
.foregroundColor(.nightPrimary)
Text("Wir prüfen den Inhalt so schnell wie möglich.")
.font(.nightBody(14))
.foregroundColor(.nightSecondary)
.multilineTextAlignment(.center)
Button("Schließen") { dismiss() }
.foregroundColor(.nightPurpleSoft)
.padding(.top, 8)
}
.padding(40)
} else {
VStack(alignment: .leading, spacing: 0) {
Text("Warum möchtest du das melden?")
.font(.nightTitle(17))
.foregroundColor(.nightPrimary)
.padding(.horizontal, 20)
.padding(.top, 24)
.padding(.bottom, 16)
ForEach(ReportReason.allCases, id: \.self) { reason in
Button {
selected = reason
} label: {
HStack {
Text(reason.rawValue)
.font(.nightBody(15))
.foregroundColor(.nightPrimary)
Spacer()
if selected == reason {
Image(systemName: "checkmark")
.foregroundColor(.nightPurpleSoft)
}
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(selected == reason ? Color.nightPurple.opacity(0.08) : Color.clear)
}
Divider().background(Color.nightBorder)
}
Spacer()
Button {
guard let reason = selected else { return }
Task { await submit(reason: reason) }
} label: {
Group {
if isLoading {
ProgressView().tint(.black)
} else {
Text("Melden")
.font(.nightLabel(16, weight: .semibold))
.foregroundColor(.black)
}
}
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(selected != nil ? Color.nightPrimary : Color.nightRaised)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(selected == nil || isLoading)
.padding(.horizontal, 20)
.padding(.bottom, 32)
}
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Abbrechen") { dismiss() }
.foregroundColor(.nightSecondary)
}
}
}
.presentationDetents([.medium])
.preferredColorScheme(.dark)
}
func submit(reason: ReportReason) async {
isLoading = true
defer { isLoading = false }
do {
try await supabase.reportPost(postId: postId, reason: reason.rawValue, details: nil)
submitted = true
} catch {
// Fehler still ignorieren Meldung trotzdem als abgeschlossen zeigen
submitted = true
}
}
}