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>
292 lines
10 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|