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>
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user