Files
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

307 lines
9.8 KiB
Swift

import SwiftUI
struct OnboardingView: View {
@EnvironmentObject var appState: AppState
@State private var phase: Phase = .welcome
@State private var isLogin = false
enum Phase { case welcome, auth }
var body: some View {
ZStack {
Color.nightBase.ignoresSafeArea()
StarField()
VStack(spacing: 0) {
Spacer()
switch phase {
case .welcome:
WelcomeScreen()
.transition(.opacity)
Spacer()
WelcomeActions(
onStart: {
isLogin = false
withAnimation(.spring(duration: 0.4)) { phase = .auth }
},
onLogin: {
isLogin = true
withAnimation(.spring(duration: 0.4)) { phase = .auth }
}
)
case .auth:
AuthScreen(isLogin: $isLogin)
.environmentObject(appState)
.transition(.move(edge: .trailing).combined(with: .opacity))
Spacer()
}
}
}
.preferredColorScheme(.dark)
}
}
// MARK: - Welcome
struct WelcomeScreen: View {
@State private var appeared = false
var body: some View {
VStack(spacing: 28) {
ZStack {
ForEach([130, 100, 70], id: \.self) { size in
Circle()
.fill(Color.nightPurple.opacity(size == 70 ? 0 : (size == 100 ? 0.08 : 0.04)))
.frame(width: CGFloat(size), height: CGFloat(size))
}
Text("")
.font(.system(size: 52))
.foregroundColor(.nightPurpleSoft)
}
.scaleEffect(appeared ? 1 : 0.75)
.opacity(appeared ? 1 : 0)
VStack(spacing: 12) {
Text("nightly")
.font(.system(size: 44, weight: .bold, design: .rounded))
.foregroundColor(.nightPrimary)
VStack(spacing: 5) {
Text("Zwischen 2 und 5 Uhr.")
Text("Kein Filter. Keine Maske.")
Text("Nur echte Gedanken.")
}
.font(.nightBody(17))
.foregroundColor(.nightSecondary)
.multilineTextAlignment(.center)
.lineSpacing(3)
}
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 10)
}
.onAppear {
withAnimation(.spring(duration: 0.8, bounce: 0.25).delay(0.1)) { appeared = true }
}
}
}
struct WelcomeActions: View {
let onStart: () -> Void
let onLogin: () -> Void
var body: some View {
VStack(spacing: 12) {
Button("loslegen", action: onStart).buttonStyle(NightlyPrimaryButton())
Button("ich hab schon einen account", action: onLogin)
.font(.nightLabel(14))
.foregroundColor(.nightSecondary)
}
.padding(.horizontal, 24)
.padding(.bottom, 52)
}
}
// MARK: - Auth Screen
struct AuthScreen: View {
@EnvironmentObject var appState: AppState
@Binding var isLogin: Bool
// Registrierung
@State private var username = ""
@State private var displayName = ""
@State private var email = ""
@State private var password = ""
@State private var isLoading = false
@State private var error: String?
var body: some View {
VStack(spacing: 22) {
Text(isLogin ? "willkommen zurück" : "mitmachen")
.font(.nightTitle(28))
.foregroundColor(.nightPrimary)
VStack(spacing: 10) {
if !isLogin {
NightlyField(text: $displayName, placeholder: "anzeigename", icon: "person")
NightlyField(text: $username, placeholder: "benutzername", icon: "at")
.textInputAutocapitalization(.never).autocorrectionDisabled()
}
NightlyField(text: $email, placeholder: "e-mail", icon: "envelope")
.textInputAutocapitalization(.never)
.keyboardType(.emailAddress)
.autocorrectionDisabled()
NightlyField(text: $password, placeholder: "passwort", icon: "lock", isSecure: true)
}
.padding(.horizontal, 24)
if let err = error {
Text(err)
.font(.nightLabel(13))
.foregroundColor(.nightRed)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
Button(isLogin ? "einloggen" : "account erstellen") {
Task { await submit() }
}
.buttonStyle(NightlyPrimaryButton(isLoading: isLoading))
.disabled(isLoading)
.padding(.horizontal, 24)
Button(isLogin ? "noch kein account?" : "schon dabei?") {
withAnimation { isLogin.toggle() }
}
.font(.nightLabel(14))
.foregroundColor(.nightSecondary)
// Rechtliches
LegalNotice()
}
}
func submit() async {
guard !email.isEmpty && !password.isEmpty else {
error = "Bitte alle Felder ausfüllen."
return
}
isLoading = true
error = nil
defer { isLoading = false }
do {
if isLogin {
try await appState.signIn(email: email, password: password)
} else {
guard !username.isEmpty && !displayName.isEmpty else {
error = "Bitte alle Felder ausfüllen."
return
}
guard username.count >= 3 else {
error = "Benutzername muss mindestens 3 Zeichen haben."
return
}
guard password.count >= 8 else {
error = "Passwort muss mindestens 8 Zeichen haben."
return
}
try await appState.signUp(
email: email,
password: password,
username: username.lowercased(),
displayName: displayName
)
}
} catch {
self.error = error.localizedDescription
}
}
}
struct LegalNotice: View {
@State private var showLegal = false
var body: some View {
VStack(spacing: 4) {
Text("Mit der Registrierung stimmst du zu:")
.font(.nightLabel(11))
.foregroundColor(.nightTertiary)
HStack(spacing: 4) {
Button("Nutzungsbedingungen") { showLegal = true }
Text("·")
Button("Datenschutzerklärung") { showLegal = true }
}
.font(.nightLabel(11, weight: .medium))
.foregroundColor(.nightSecondary)
}
.multilineTextAlignment(.center)
.sheet(isPresented: $showLegal) {
LegalView()
}
}
}
// MARK: - Reusable components
struct NightlyField: View {
@Binding var text: String
let placeholder: String
let icon: String
var isSecure = false
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 15))
.foregroundColor(.nightSecondary)
.frame(width: 18)
Group {
if isSecure { SecureField(placeholder, text: $text) }
else { TextField(placeholder, text: $text) }
}
.font(.nightBody(16))
.foregroundColor(.nightPrimary)
}
.padding(16)
.background(Color.nightSurface)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Color.nightBorder, lineWidth: 1))
.tint(.nightPurpleSoft)
}
}
struct NightlyPrimaryButton: ButtonStyle {
var isLoading = false
func makeBody(configuration: Configuration) -> some View {
Group {
if isLoading { ProgressView().tint(.nightBase).frame(maxWidth: .infinity, maxHeight: 52) }
else {
configuration.label
.font(.nightLabel(17, weight: .semibold))
.foregroundColor(.nightBase)
.frame(maxWidth: .infinity).frame(height: 52)
}
}
.background(Color.nightPrimary.opacity(configuration.isPressed ? 0.85 : 1))
.clipShape(RoundedRectangle(cornerRadius: 14))
.scaleEffect(configuration.isPressed ? 0.98 : 1)
.animation(.spring(duration: 0.2), value: configuration.isPressed)
}
}
struct StarField: View {
struct Star: Identifiable {
let id: Int; let x, y, size, opacity: CGFloat
}
private let stars: [Star] = (0..<120).map {
Star(id: $0,
x: .random(in: 0...1),
y: .random(in: 0...1),
size: .random(in: 1...2.5),
opacity: .random(in: 0.07...0.3))
}
@State private var twinkle = false
var body: some View {
GeometryReader { geo in
ForEach(stars) { s in
Circle().fill(Color.white)
.frame(width: s.size, height: s.size)
.opacity(twinkle && s.id % 5 == 0 ? s.opacity * 0.3 : s.opacity)
.position(x: s.x * geo.size.width, y: s.y * geo.size.height)
}
}
.ignoresSafeArea()
.onAppear {
withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) { twinkle = true }
}
}
}