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>
321 lines
12 KiB
Swift
321 lines
12 KiB
Swift
import SwiftUI
|
|
|
|
struct ComposeView: View {
|
|
@EnvironmentObject var appState: AppState
|
|
@Environment(\.dismiss) var dismiss
|
|
|
|
@State private var text = ""
|
|
@State private var selectedMood: Mood? = nil
|
|
@State private var isAnonymous = false
|
|
@State private var isPosting = false
|
|
@State private var errorMessage: String?
|
|
|
|
private let maxChars = 280
|
|
var remaining: Int { maxChars - text.count }
|
|
var canPost: Bool { !text.trimmingCharacters(in: .whitespaces).isEmpty && selectedMood != nil }
|
|
|
|
// Background tint based on mood
|
|
var moodBackground: Color {
|
|
selectedMood?.color.opacity(0.06) ?? .clear
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
Color.nightBase.ignoresSafeArea()
|
|
moodBackground.ignoresSafeArea()
|
|
.animation(.easeInOut(duration: 0.5), value: selectedMood)
|
|
|
|
VStack(spacing: 0) {
|
|
// Top meta bar
|
|
HStack {
|
|
Label(currentTime, systemImage: "moon.stars.fill")
|
|
.font(.nightMono(12))
|
|
.foregroundColor(.nightPurple.opacity(0.7))
|
|
.labelStyle(.titleAndIcon)
|
|
|
|
Spacer()
|
|
|
|
// Character count
|
|
Group {
|
|
if remaining <= 30 {
|
|
Text("\(remaining)")
|
|
.foregroundColor(remaining <= 10 ? .nightRed : .nightSecondary)
|
|
}
|
|
}
|
|
.font(.nightMono(13))
|
|
.animation(.easeInOut, value: remaining)
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 12)
|
|
|
|
Divider().background(Color.nightBorder)
|
|
|
|
// Text field area
|
|
ScrollView {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
// Left: Avatar
|
|
VStack(spacing: 0) {
|
|
if isAnonymous {
|
|
AnonymousAvatar(size: 38)
|
|
} else if let user = appState.currentUser {
|
|
AvatarView(user: user, size: 38)
|
|
} else {
|
|
Circle()
|
|
.fill(Color.nightRaised)
|
|
.frame(width: 38, height: 38)
|
|
}
|
|
// Connector line (visual polish)
|
|
Rectangle()
|
|
.fill(Color.nightBorder)
|
|
.frame(width: 1)
|
|
.frame(maxHeight: .infinity)
|
|
.padding(.top, 8)
|
|
}
|
|
.frame(width: 38)
|
|
|
|
// Right: Content
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
// Name
|
|
Text(isAnonymous ? "anonym" : (appState.currentUser?.displayName ?? ""))
|
|
.font(.nightLabel(15, weight: .semibold))
|
|
.foregroundColor(isAnonymous ? .nightSecondary : .nightPrimary)
|
|
.italic(isAnonymous)
|
|
|
|
// TextEditor with placeholder
|
|
ZStack(alignment: .topLeading) {
|
|
if text.isEmpty {
|
|
Text("Was geht dir gerade durch den Kopf?")
|
|
.font(.nightBody(17))
|
|
.foregroundColor(.nightTertiary)
|
|
.allowsHitTesting(false)
|
|
.padding(.top, 8)
|
|
.padding(.leading, 5)
|
|
}
|
|
TextEditor(text: $text)
|
|
.scrollContentBackground(.hidden)
|
|
.background(.clear)
|
|
.foregroundColor(.nightPrimary)
|
|
.font(.nightBody(17))
|
|
.lineSpacing(5)
|
|
.frame(minHeight: 160)
|
|
.onChange(of: text) { _, new in
|
|
if new.count > maxChars {
|
|
text = String(new.prefix(maxChars))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mood picker inline
|
|
MoodPickerRow(selected: $selectedMood)
|
|
.padding(.top, 4)
|
|
|
|
Spacer().frame(height: 20)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 18)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Bottom bar: anonymous toggle + countdown
|
|
Divider().background(Color.nightBorder)
|
|
|
|
HStack(spacing: 14) {
|
|
// Anonymous toggle
|
|
Button {
|
|
withAnimation(.spring(duration: 0.3, bounce: 0.4)) {
|
|
isAnonymous.toggle()
|
|
}
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: isAnonymous ? "theatermasks.fill" : "theatermasks")
|
|
.font(.system(size: 15))
|
|
Text(isAnonymous ? "anonym" : "anonym posten")
|
|
.font(.nightLabel(13))
|
|
}
|
|
.foregroundColor(isAnonymous ? .nightPrimary : .nightSecondary)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 7)
|
|
.background(isAnonymous ? Color.nightRaised : .clear)
|
|
.clipShape(Capsule())
|
|
.overlay(
|
|
Capsule()
|
|
.strokeBorder(
|
|
isAnonymous ? Color.nightBorder : .clear,
|
|
lineWidth: 1
|
|
)
|
|
)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
WindowCountdownView()
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 10)
|
|
.padding(.bottom, 8)
|
|
|
|
if let err = errorMessage {
|
|
Text(err)
|
|
.font(.nightLabel(13))
|
|
.foregroundColor(.nightRed)
|
|
.padding(.horizontal, 20)
|
|
.padding(.bottom, 8)
|
|
}
|
|
}
|
|
}
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("Abbrechen") { dismiss() }
|
|
.foregroundColor(.nightSecondary)
|
|
.font(.nightBody(16))
|
|
}
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
PostButton(canPost: canPost, isPosting: isPosting) {
|
|
Task { await submit() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.preferredColorScheme(.dark)
|
|
}
|
|
|
|
var currentTime: String {
|
|
let f = DateFormatter(); f.dateFormat = "HH:mm"
|
|
return f.string(from: Date())
|
|
}
|
|
|
|
func submit() async {
|
|
guard let mood = selectedMood else { return }
|
|
isPosting = true
|
|
errorMessage = nil
|
|
defer { isPosting = false }
|
|
do {
|
|
try await APIService.shared.createPost(
|
|
content: text.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
mood: mood,
|
|
isAnonymous: isAnonymous
|
|
)
|
|
appState.markAsPosted()
|
|
dismiss()
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Mood Picker
|
|
|
|
struct MoodPickerRow: View {
|
|
@Binding var selected: Mood?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("stimmung")
|
|
.font(.nightLabel(11, weight: .medium))
|
|
.foregroundColor(.nightTertiary)
|
|
.kerning(0.8)
|
|
|
|
HStack(spacing: 7) {
|
|
ForEach(Mood.allCases, id: \.self) { mood in
|
|
MoodChip(mood: mood, isSelected: selected == mood) {
|
|
withAnimation(.spring(duration: 0.3, bounce: 0.3)) {
|
|
selected = selected == mood ? nil : mood
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MoodChip: View {
|
|
let mood: Mood
|
|
let isSelected: Bool
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack(spacing: 5) {
|
|
Text(mood.emoji)
|
|
.font(.nightMono(12))
|
|
.foregroundColor(isSelected ? mood.color : .nightSecondary)
|
|
Text(mood.label)
|
|
.font(.nightLabel(12, weight: isSelected ? .semibold : .regular))
|
|
.foregroundColor(isSelected ? .nightPrimary : .nightSecondary)
|
|
}
|
|
.padding(.horizontal, 11)
|
|
.padding(.vertical, 7)
|
|
.background(
|
|
ZStack {
|
|
if isSelected {
|
|
Capsule().fill(mood.color.opacity(0.14))
|
|
Capsule().strokeBorder(mood.color.opacity(0.4), lineWidth: 1)
|
|
} else {
|
|
Capsule().fill(Color.nightRaised)
|
|
Capsule().strokeBorder(Color.nightBorder, lineWidth: 1)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Post Button
|
|
|
|
struct PostButton: View {
|
|
let canPost: Bool
|
|
let isPosting: Bool
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
Group {
|
|
if isPosting {
|
|
ProgressView().tint(.black).frame(width: 20, height: 20)
|
|
} else {
|
|
Text("posten")
|
|
.font(.nightLabel(15, weight: .bold))
|
|
.foregroundColor(canPost ? Color.nightBase : .nightTertiary)
|
|
}
|
|
}
|
|
.frame(width: 74, height: 34)
|
|
.background(canPost ? Color.nightPrimary : Color.nightRaised)
|
|
.clipShape(Capsule())
|
|
}
|
|
.disabled(!canPost || isPosting)
|
|
.animation(.easeInOut(duration: 0.2), value: canPost)
|
|
}
|
|
}
|
|
|
|
// MARK: - Countdown
|
|
|
|
struct WindowCountdownView: View {
|
|
@State private var label = ""
|
|
|
|
var body: some View {
|
|
HStack(spacing: 5) {
|
|
Image(systemName: "clock")
|
|
.font(.system(size: 11))
|
|
Text(label)
|
|
.font(.nightMono(11))
|
|
}
|
|
.foregroundColor(.nightPurple.opacity(0.5))
|
|
.onAppear { tick() }
|
|
.onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in tick() }
|
|
}
|
|
|
|
func tick() {
|
|
var c = Calendar.current.dateComponents([.year, .month, .day], from: Date())
|
|
c.hour = 5; c.minute = 0; c.second = 0
|
|
guard let end = Calendar.current.date(from: c) else { return }
|
|
let diff = max(0, Int(end.timeIntervalSince(Date())))
|
|
label = diff > 0
|
|
? String(format: "%d:%02d bis 05:00", diff / 60, diff % 60)
|
|
: "fenster zu"
|
|
}
|
|
}
|