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>
307 lines
9.8 KiB
Swift
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 }
|
|
}
|
|
}
|
|
}
|