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,306 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user