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>
386 lines
13 KiB
Swift
386 lines
13 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Feed
|
|
|
|
struct FeedView: View {
|
|
@StateObject private var viewModel = FeedViewModel()
|
|
@EnvironmentObject var appState: AppState
|
|
var realtime: RealtimeService? = nil
|
|
|
|
// Gerade Jetzt = posts younger than 10 minutes
|
|
var rightNowPosts: [Post] { viewModel.posts.filter { $0.isRightNow } }
|
|
var nightPosts: [Post] { viewModel.posts }
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
Color.nightBase.ignoresSafeArea()
|
|
|
|
if viewModel.posts.isEmpty && !viewModel.isLoading {
|
|
EmptyNightView(windowState: appState.windowState)
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(spacing: 0) {
|
|
|
|
// ── GERADE JETZT ──────────────────────────────────
|
|
// Nur sichtbar wenn: Fenster offen ODER du hast gerade gepostet
|
|
// UND es gibt Leute die gleichzeitig posten
|
|
if !rightNowPosts.isEmpty && appState.windowState == .posted {
|
|
RightNowSection(
|
|
posts: rightNowPosts,
|
|
onResonate: { post in
|
|
Task { await viewModel.resonate(post) }
|
|
}
|
|
)
|
|
.padding(.bottom, 2)
|
|
}
|
|
|
|
// ── TRENNLINIE MIT KONTEXT ──────────────────────
|
|
NightContextBar(
|
|
windowState: appState.windowState,
|
|
totalCount: nightPosts.count,
|
|
liveCount: rightNowPosts.count
|
|
)
|
|
|
|
// ── HEUTE NACHT ────────────────────────────────
|
|
// Alle Posts dieser Nacht, chronologisch
|
|
ForEach(nightPosts) { post in
|
|
PostRowView(post: post) {
|
|
Task { await viewModel.resonate(post) }
|
|
}
|
|
Divider()
|
|
.background(Color.nightBorder)
|
|
.padding(.leading, 16)
|
|
}
|
|
|
|
if viewModel.isLoading {
|
|
ProgressView()
|
|
.tint(.nightPurple)
|
|
.padding(40)
|
|
}
|
|
|
|
Color.clear.frame(height: 100)
|
|
}
|
|
}
|
|
.refreshable { await viewModel.load() }
|
|
}
|
|
}
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .principal) {
|
|
NightlyWordmark()
|
|
}
|
|
}
|
|
}
|
|
.task {
|
|
await viewModel.load()
|
|
if let realtime {
|
|
await realtime.startListening { [weak viewModel] post in
|
|
viewModel?.prepend(post)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Wordmark
|
|
|
|
struct NightlyWordmark: View {
|
|
var body: some View {
|
|
HStack(spacing: 7) {
|
|
Text("◐")
|
|
.font(.system(size: 15))
|
|
.foregroundColor(.nightPurple)
|
|
Text("nightly")
|
|
.font(.system(size: 17, weight: .bold, design: .rounded))
|
|
.foregroundColor(.nightPrimary)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Gerade Jetzt Section
|
|
//
|
|
// WANN ERSCHEINT DAS?
|
|
// → Du hast in den letzten 10 Minuten gepostet
|
|
// → Und mindestens eine andere Person auch
|
|
//
|
|
// WAS IST DER UNTERSCHIED ZU "HEUTE NACHT"?
|
|
// → Gerade Jetzt = buchstäblich gerade, gleichzeitig, diese Minute
|
|
// → Heute Nacht = alle Posts seit dem Öffnen des Fensters
|
|
//
|
|
// ANALOGIE: Gerade Jetzt = du bist gerade im selben Raum wie jemand.
|
|
// Heute Nacht = der gesamte Raum-Verlauf dieser Nacht.
|
|
|
|
struct RightNowSection: View {
|
|
let posts: [Post]
|
|
let onResonate: (Post) -> Void
|
|
|
|
@State private var pulse = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Header
|
|
HStack(spacing: 10) {
|
|
// Pulsierender grüner Punkt = LIVE
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.nightGreen.opacity(0.25))
|
|
.frame(width: 16, height: 16)
|
|
.scaleEffect(pulse ? 1.8 : 1.0)
|
|
.opacity(pulse ? 0 : 1)
|
|
Circle()
|
|
.fill(Color.nightGreen)
|
|
.frame(width: 7, height: 7)
|
|
}
|
|
.onAppear {
|
|
withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: false)) {
|
|
pulse = true
|
|
}
|
|
}
|
|
|
|
Text("gerade jetzt")
|
|
.font(.nightLabel(12, weight: .semibold))
|
|
.foregroundColor(.nightGreen)
|
|
.kerning(0.8)
|
|
|
|
Text("· \(posts.count) \(posts.count == 1 ? "Person" : "Personen") gleichzeitig wach")
|
|
.font(.nightLabel(12))
|
|
.foregroundColor(.nightSecondary)
|
|
|
|
Spacer()
|
|
|
|
// Info-Tooltip
|
|
HelpTooltip(
|
|
text: "Leute die in den letzten 10 Minuten gepostet haben — ihr seid buchstäblich gleichzeitig wach."
|
|
)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 12)
|
|
|
|
// Horizontal Cards
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 10) {
|
|
ForEach(posts) { post in
|
|
RightNowCard(post: post, onResonate: { onResonate(post) })
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.bottom, 16)
|
|
}
|
|
}
|
|
.background(
|
|
ZStack {
|
|
Color.nightSurface
|
|
Color.nightGreen.opacity(0.025)
|
|
}
|
|
)
|
|
.overlay(
|
|
Rectangle()
|
|
.fill(Color.nightGreen.opacity(0.15))
|
|
.frame(height: 1),
|
|
alignment: .bottom
|
|
)
|
|
}
|
|
}
|
|
|
|
struct RightNowCard: View {
|
|
let post: Post
|
|
let onResonate: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(spacing: 8) {
|
|
if post.isAnonymous {
|
|
AnonymousAvatar(size: 26)
|
|
} else {
|
|
AvatarView(user: post.author, size: 26)
|
|
}
|
|
Text(post.isAnonymous ? "anonym" : "@\(post.author.username)")
|
|
.font(.nightLabel(12))
|
|
.foregroundColor(post.isAnonymous ? .nightSecondary : .nightPrimary)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
if let mood = post.mood {
|
|
Text(mood.emoji)
|
|
.font(.nightMono(11))
|
|
.foregroundColor(mood.color.opacity(0.7))
|
|
}
|
|
}
|
|
|
|
Text(post.content)
|
|
.font(.nightBody(14))
|
|
.foregroundColor(.nightPrimary.opacity(0.88))
|
|
.lineSpacing(4)
|
|
.lineLimit(4)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
Button(action: onResonate) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: post.hasResonated ? "heart.fill" : "heart")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(post.hasResonated ? .nightRed : .nightSecondary)
|
|
if post.resonanceCount > 0 {
|
|
Text("\(post.resonanceCount)")
|
|
.font(.nightLabel(11))
|
|
.foregroundColor(.nightSecondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(14)
|
|
.frame(width: 210, height: 148)
|
|
.background(
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.fill(Color.nightRaised)
|
|
if let mood = post.mood {
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [mood.color.opacity(0.07), .clear],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
}
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.strokeBorder(Color.nightGreen.opacity(0.18), lineWidth: 1)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Context Bar (der Übergang zwischen Gerade Jetzt und Heute Nacht)
|
|
|
|
struct NightContextBar: View {
|
|
let windowState: AppState.WindowState
|
|
let totalCount: Int
|
|
let liveCount: Int
|
|
|
|
var body: some View {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
HStack(spacing: 6) {
|
|
Text("heute nacht")
|
|
.font(.nightLabel(12, weight: .semibold))
|
|
.foregroundColor(.nightSecondary)
|
|
.kerning(0.5)
|
|
|
|
if totalCount > 0 {
|
|
Text("· \(totalCount) Gedanken")
|
|
.font(.nightLabel(12))
|
|
.foregroundColor(.nightTertiary)
|
|
}
|
|
}
|
|
|
|
Text(statusSubtitle)
|
|
.font(.nightLabel(11))
|
|
.foregroundColor(.nightTertiary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Window status pill
|
|
HStack(spacing: 5) {
|
|
Circle()
|
|
.fill(windowState == .open ? Color.nightGreen : Color.nightTertiary)
|
|
.frame(width: 6, height: 6)
|
|
Text(windowState == .open ? "offen" : "geschlossen")
|
|
.font(.nightLabel(11))
|
|
.foregroundColor(.nightSecondary)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 5)
|
|
.background(Color.nightRaised)
|
|
.clipShape(Capsule())
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
.overlay(
|
|
Rectangle()
|
|
.fill(Color.nightBorder)
|
|
.frame(height: 1),
|
|
alignment: .bottom
|
|
)
|
|
}
|
|
|
|
var statusSubtitle: String {
|
|
switch windowState {
|
|
case .open: return "Du kannst noch posten — bis 05:00"
|
|
case .posted: return "Dein Post ist sichtbar bis morgen früh"
|
|
case .closed: return "Fenster öffnet später heute Nacht"
|
|
case .missed: return "Nächste Chance: heute Nacht"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Help Tooltip
|
|
|
|
struct HelpTooltip: View {
|
|
let text: String
|
|
@State private var show = false
|
|
|
|
var body: some View {
|
|
Button {
|
|
withAnimation(.spring(duration: 0.3)) { show.toggle() }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
|
withAnimation { show = false }
|
|
}
|
|
} label: {
|
|
Image(systemName: "info.circle")
|
|
.font(.system(size: 13))
|
|
.foregroundColor(.nightTertiary)
|
|
}
|
|
.overlay(alignment: .topTrailing) {
|
|
if show {
|
|
Text(text)
|
|
.font(.nightBody(12))
|
|
.foregroundColor(.nightPrimary)
|
|
.padding(10)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(Color.nightRaised)
|
|
.shadow(color: .black.opacity(0.4), radius: 8, y: 4)
|
|
)
|
|
.frame(width: 200)
|
|
.offset(x: -160, y: 28)
|
|
.transition(.opacity.combined(with: .scale(scale: 0.9, anchor: .topTrailing)))
|
|
.zIndex(100)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Empty State
|
|
|
|
struct EmptyNightView: View {
|
|
let windowState: AppState.WindowState
|
|
|
|
var body: some View {
|
|
VStack(spacing: 20) {
|
|
Text("◐")
|
|
.font(.system(size: 52))
|
|
.foregroundColor(.nightPurple.opacity(0.4))
|
|
|
|
VStack(spacing: 8) {
|
|
Text(windowState == .open ? "sei der erste heute nacht" : "noch ruhig hier")
|
|
.font(.nightTitle(19))
|
|
.foregroundColor(.nightPrimary)
|
|
|
|
Text(windowState == .open
|
|
? "Das Fenster ist offen.\nPoste einen Gedanken — andere sind auch wach."
|
|
: "Wenn dein Fenster öffnet, kannst du posten.\nErst dann siehst du alle anderen."
|
|
)
|
|
.font(.nightBody(15))
|
|
.foregroundColor(.nightSecondary)
|
|
.multilineTextAlignment(.center)
|
|
.lineSpacing(4)
|
|
}
|
|
}
|
|
.padding(40)
|
|
}
|
|
}
|