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:
denshooter
2026-04-23 23:31:38 +02:00
commit 5bc81d5b3b
80 changed files with 9958 additions and 0 deletions
+124
View File
@@ -0,0 +1,124 @@
import SwiftUI
import Supabase
@MainActor
class AppState: ObservableObject {
@Published var isAuthenticated = false
@Published var currentUser: User?
@Published var windowState: WindowState = .closed
private var windowTimer: Timer?
enum WindowState { case closed, open, posted, missed }
init() {
Task { await checkSession() }
startWindowTimer()
observeAuthChanges()
}
// MARK: - Auth
func checkSession() async {
do {
let session = try await supabase.auth.session
isAuthenticated = true
await loadProfile(userId: session.user.id)
} catch {
isAuthenticated = false
}
}
func signIn(email: String, password: String) async throws {
try await supabase.signIn(email: email, password: password)
let session = try await supabase.auth.session
isAuthenticated = true
await loadProfile(userId: session.user.id)
}
func signIn(username: String, password: String) async throws {
try await supabase.signIn(username: username, password: password)
let session = try await supabase.auth.session
isAuthenticated = true
await loadProfile(userId: session.user.id)
}
func signUp(email: String, password: String, username: String, displayName: String) async throws {
try await supabase.signUp(email: email, password: password, username: username, displayName: displayName)
let session = try await supabase.auth.session
isAuthenticated = true
await loadProfile(userId: session.user.id)
}
func signOut() {
Task {
try? await supabase.auth.signOut()
}
isAuthenticated = false
currentUser = nil
}
func deleteAccount() async throws {
try await supabase.deleteAccount()
isAuthenticated = false
currentUser = nil
}
private func loadProfile(userId: UUID) async {
guard let profile = try? await supabase.getMyProfile() else { return }
currentUser = User(
id: profile.id.uuidString,
username: profile.username,
displayName: profile.displayName,
bio: profile.bio,
avatarURL: profile.avatarUrl.flatMap(URL.init),
followerCount: 0,
followingCount: 0,
postCount: 0,
isFollowing: false
)
}
private func observeAuthChanges() {
Task {
for await (event, session) in await supabase.auth.authStateChanges {
switch event {
case .signedIn:
if let session {
isAuthenticated = true
await loadProfile(userId: session.user.id)
}
case .signedOut, .userDeleted:
isAuthenticated = false
currentUser = nil
default:
break
}
}
}
}
// MARK: - Window State
func updateWindowState() {
let hour = Calendar.current.component(.hour, from: Date())
guard hour >= 2 && hour < 5 else { windowState = .closed; return }
let hasPosted = UserDefaults.standard.object(forKey: "lastPostDate")
.flatMap { $0 as? Date }
.map { Calendar.current.isDateInToday($0) } ?? false
windowState = hasPosted ? .posted : .open
}
func markAsPosted() {
UserDefaults.standard.set(Date(), forKey: "lastPostDate")
updateWindowState()
}
private func startWindowTimer() {
updateWindowState()
windowTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in self?.updateWindowState() }
}
}
}
+79
View File
@@ -0,0 +1,79 @@
import SwiftUI
// MARK: - Design Tokens
extension Color {
// Backgrounds kein reines Schwarz, sondern Mitternachtsblau
static let nightBase = Color(hex: "080810") // Haupt-Hintergrund
static let nightSurface = Color(hex: "0E0E1C") // Karten, Sheets
static let nightRaised = Color(hex: "151528") // Elevated surfaces
static let nightBorder = Color(white: 1, opacity: 0.06)
// Text
static let nightPrimary = Color(hex: "EEEEF8")
static let nightSecondary = Color(hex: "64647A")
static let nightTertiary = Color(hex: "3A3A52")
// Akzente
static let nightPurple = Color(hex: "7B4FE8")
static let nightPurpleSoft = Color(hex: "9B77F0")
static let nightGreen = Color(hex: "34D399")
static let nightRed = Color(hex: "F27474")
// Hex initializer
init(hex: String) {
let h = hex.trimmingCharacters(in: .alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: h).scanHexInt64(&int)
let a, r, g, b: UInt64
switch h.count {
case 6: (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:(a, r, g, b) = (255, 255, 255, 255)
}
self.init(.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255)
}
}
// MARK: - Mood (passt hier semantisch besser rein als in Post.swift)
extension Mood {
var color: Color {
switch self {
case .still: return Color(hex: "4A9EFF")
case .unruhig: return Color(hex: "FF8C42")
case .melancholisch: return Color(hex: "A855F7")
case .aufgedreht: return Color(hex: "10D08A")
}
}
var label: String { rawValue }
var emoji: String {
switch self {
case .still: return ""
case .unruhig: return ""
case .melancholisch: return ""
case .aufgedreht: return ""
}
}
}
// MARK: - Typography helpers
extension Font {
static func nightTitle(_ size: CGFloat) -> Font {
.system(size: size, weight: .bold, design: .rounded)
}
static func nightBody(_ size: CGFloat) -> Font {
.system(size: size, weight: .regular)
}
static func nightMono(_ size: CGFloat) -> Font {
.system(size: size, design: .monospaced)
}
static func nightLabel(_ size: CGFloat, weight: Font.Weight = .medium) -> Font {
.system(size: size, weight: weight)
}
}
+107
View File
@@ -0,0 +1,107 @@
import Foundation
// MARK: - Mood
enum Mood: String, Codable, CaseIterable {
case still = "still"
case unruhig = "unruhig"
case melancholisch = "melancholisch"
case aufgedreht = "aufgedreht"
// color, label, emoji Colors.swift extension
}
// MARK: - Post
struct Post: Identifiable, Codable {
let id: String
let author: User
let content: String
let mood: Mood?
let createdAt: Date
var resonanceCount: Int // "Hat mich getroffen"
var hasResonated: Bool // Current user's reaction
var commentCount: Int
let isAnonymous: Bool
let nightOf: Date
var isExpired: Bool {
Date().timeIntervalSince(createdAt) > 14 * 3_600
}
// Is this post in the "Gerade Jetzt" window (< 10 min old)
var isRightNow: Bool {
Date().timeIntervalSince(createdAt) < 10 * 60
}
var formattedTime: String {
let f = DateFormatter()
f.dateFormat = "HH:mm"
return f.string(from: createdAt)
}
var timeAgo: String {
let diff = Date().timeIntervalSince(createdAt)
if diff < 60 { return "gerade eben" }
if diff < 3_600 { return "\(Int(diff / 60))m" }
return "\(Int(diff / 3_600))h"
}
static let previews: [Post] = [
Post(
id: "1",
author: .preview,
content: "warum denk ich um 3 uhr morgens noch an das was ich 2019 gesagt hab",
mood: .melancholisch,
createdAt: Date().addingTimeInterval(-180),
resonanceCount: 12,
hasResonated: false,
commentCount: 3,
isAnonymous: false,
nightOf: Date()
),
Post(
id: "2",
author: User(
id: "2", username: "insomniac_", displayName: "can't sleep",
bio: nil, avatarURL: nil,
followerCount: 88, followingCount: 44, postCount: 12, isFollowing: true
),
content: "das licht vom handy macht alles schlimmer aber ich leg es trotzdem nicht weg",
mood: .unruhig,
createdAt: Date().addingTimeInterval(-900),
resonanceCount: 8,
hasResonated: true,
commentCount: 1,
isAnonymous: false,
nightOf: Date()
),
Post(
id: "3",
author: .preview,
content: "ich warte irgendwie immer noch auf eine nachricht von dir obwohl ich weiß dass sie nicht kommt",
mood: .melancholisch,
createdAt: Date().addingTimeInterval(-300),
resonanceCount: 31,
hasResonated: true,
commentCount: 7,
isAnonymous: true,
nightOf: Date()
),
Post(
id: "4",
author: User(
id: "4", username: "felix.nacht", displayName: "Felix",
bio: nil, avatarURL: nil,
followerCount: 33, followingCount: 20, postCount: 8, isFollowing: false
),
content: "hab gerade realisiert dass ich seit 4 stunden auf tiktok bin und morgen um 7 aufstehen muss",
mood: .aufgedreht,
createdAt: Date().addingTimeInterval(-60),
resonanceCount: 5,
hasResonated: false,
commentCount: 0,
isAnonymous: false,
nightOf: Date()
)
]
}
+25
View File
@@ -0,0 +1,25 @@
import Foundation
struct User: Identifiable, Codable, Equatable {
let id: String
let username: String
var displayName: String
var bio: String?
var avatarURL: URL?
var followerCount: Int
var followingCount: Int
var postCount: Int
var isFollowing: Bool
static let preview = User(
id: "preview",
username: "nightowl",
displayName: "Night Owl",
bio: "3 Uhr ist meine goldene Stunde",
avatarURL: nil,
followerCount: 142,
followingCount: 89,
postCount: 37,
isFollowing: false
)
}
+67
View File
@@ -0,0 +1,67 @@
import SwiftUI
@main
struct NightlyApp: App {
@StateObject private var appState = AppState()
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(appState)
.preferredColorScheme(.dark)
}
}
}
// MARK: - App Delegate
class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
UNUserNotificationCenter.current().delegate = self
return true
}
// Push Notifications: erfordert bezahlten Apple Developer Account ($99/Jahr)
// Ohne Developer-Account kann dieser Code nicht getestet werden (nur Simulator ohne Pushs)
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
Task { try? await supabase.savePushToken(token) }
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
print("APNs Registrierung fehlgeschlagen:", error.localizedDescription)
// Häufige Ursache: kein bezahlter Developer Account
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .sound, .badge])
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
NotificationCenter.default.post(name: .nightlyPingReceived, object: nil)
completionHandler()
}
}
extension Notification.Name {
static let nightlyPingReceived = Notification.Name("nightlyPingReceived")
}
+35
View File
@@ -0,0 +1,35 @@
import Foundation
/// Konfiguration aus dem Xcode Build-System (xcconfig / Info.plist).
///
/// Setup:
/// 1. Datei `Config.xcconfig` im Projektverzeichnis anlegen (nicht committen!):
/// SUPABASE_URL = https://api.xxx.dk0.dev
/// SUPABASE_ANON_KEY = eyJhbGci...
///
/// 2. In Xcode: Project Info Configurations Debug & Release auf Config.xcconfig setzen
/// 3. In Info.plist eintragen:
/// SUPABASE_URL $(SUPABASE_URL)
/// SUPABASE_ANON_KEY $(SUPABASE_ANON_KEY)
enum Config {
static let supabaseURL: URL = {
guard
let raw = Bundle.main.object(forInfoDictionaryKey: "SUPABASE_URL") as? String,
!raw.isEmpty,
let url = URL(string: raw)
else {
// Fallback für Entwicklung ersetze mit deiner URL
return URL(string: "https://api.xxx.dk0.dev")!
}
return url
}()
static let supabaseAnonKey: String = {
let key = Bundle.main.object(forInfoDictionaryKey: "SUPABASE_ANON_KEY") as? String ?? ""
if key.isEmpty {
print("⚠️ SUPABASE_ANON_KEY nicht gesetzt — Config.xcconfig prüfen")
}
return key
}()
}
+127
View File
@@ -0,0 +1,127 @@
import Foundation
import UIKit
actor APIService {
static let shared = APIService()
// Change to your server URL
private let baseURL = URL(string: "https://api.nightly.app/")!
private var authToken: String? { KeychainService.shared.getToken() }
// MARK: - Auth
func login(username: String, password: String) async throws {
let r: AuthResponse = try await post("auth/login", body: [
"username": username, "password": password
])
KeychainService.shared.saveToken(r.token)
}
func register(username: String, password: String, displayName: String) async throws {
let r: AuthResponse = try await post("auth/register", body: [
"username": username, "password": password, "displayName": displayName
])
KeychainService.shared.saveToken(r.token)
}
func getCurrentUser() async throws -> User {
try await get("users/me")
}
// MARK: - Posts
func getFeed() async throws -> [Post] {
try await get("posts/feed")
}
func createPost(content: String, mood: Mood, isAnonymous: Bool) async throws {
let _: EmptyResponse = try await post("posts", body: [
"content": content,
"mood": mood.rawValue,
"isAnonymous": isAnonymous
])
}
func resonate(postId: String) async throws {
let _: EmptyResponse = try await post("posts/\(postId)/resonate", body: [:])
}
func unresoante(postId: String) async throws {
let _: EmptyResponse = try await delete("posts/\(postId)/resonate")
}
func sendWhisper(toUserId: String, content: String) async throws {
let _: EmptyResponse = try await post("users/\(toUserId)/whisper", body: ["content": content])
}
// MARK: - Users
func getUserPosts(userId: String) async throws -> [Post] {
try await get("users/\(userId)/posts")
}
func getUserStreak(userId: String) async throws -> Int {
let r: StreakResponse = try await get("users/\(userId)/streak")
return r.streak
}
func registerPushToken(_ token: String) async {
_ = try? await post("users/me/push-token", body: ["token": token]) as EmptyResponse
}
// MARK: - HTTP
private func get<T: Decodable>(_ path: String) async throws -> T {
try await perform(makeRequest("GET", path: path))
}
@discardableResult
private func post<T: Decodable>(_ path: String, body: [String: Any]) async throws -> T {
var req = makeRequest("POST", path: path)
req.httpBody = try JSONSerialization.data(withJSONObject: body)
return try await perform(req)
}
@discardableResult
private func delete<T: Decodable>(_ path: String) async throws -> T {
try await perform(makeRequest("DELETE", path: path))
}
private func makeRequest(_ method: String, path: String) -> URLRequest {
var req = URLRequest(url: baseURL.appendingPathComponent(path))
req.httpMethod = method
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let t = authToken { req.setValue("Bearer \(t)", forHTTPHeaderField: "Authorization") }
return req
}
private func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
guard (200...299).contains(http.statusCode) else {
let msg = (try? JSONDecoder().decode(APIErrorBody.self, from: data))?.message
?? HTTPURLResponse.localizedString(forStatusCode: http.statusCode)
throw APIError.serverError(msg)
}
let dec = JSONDecoder()
dec.dateDecodingStrategy = .iso8601
return try dec.decode(T.self, from: data)
}
}
private struct AuthResponse: Decodable { let token: String }
private struct StreakResponse: Decodable { let streak: Int }
private struct APIErrorBody: Decodable { let message: String }
struct EmptyResponse: Decodable {}
enum APIError: LocalizedError {
case invalidResponse
case serverError(String)
var errorDescription: String? {
switch self {
case .invalidResponse: return "Ungültige Serverantwort"
case .serverError(let m): return m
}
}
}
@@ -0,0 +1,43 @@
import Foundation
import Security
final class KeychainService {
static let shared = KeychainService()
private let service = "app.nightly"
private let account = "authToken"
func saveToken(_ token: String) {
let data = Data(token.utf8)
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecValueData: data
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
func getToken() -> String? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
func deleteToken() {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account
]
SecItemDelete(query as CFDictionary)
}
}
@@ -0,0 +1,16 @@
import UserNotifications
import UIKit
final class NotificationService {
static let shared = NotificationService()
func requestPermission() async -> Bool {
(try? await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
}
@MainActor
func registerForRemoteNotifications() {
UIApplication.shared.registerForRemoteNotifications()
}
}
@@ -0,0 +1,86 @@
import Foundation
import Supabase
/// Verwaltet die Echtzeit-Verbindung für "Gerade Jetzt".
/// Neue Posts erscheinen sofort ohne Polling.
@MainActor
class RealtimeService: ObservableObject {
@Published var newPostsCount = 0
private var channel: RealtimeChannelV2?
private var onNewPost: ((Post) -> Void)?
func startListening(onNewPost: @escaping (Post) -> Void) async {
self.onNewPost = onNewPost
guard channel == nil else { return }
let ch = await supabase.channel("public:posts")
// Neue Posts in Echtzeit empfangen
let stream = await ch.postgresChange(
InsertAction.self,
schema: "public",
table: "posts"
)
await ch.subscribe()
self.channel = ch
// Stream im Hintergrund konsumieren
Task { [weak self] in
for await action in stream {
await self?.handleInsert(action)
}
}
}
func stopListening() async {
if let ch = channel {
await supabase.removeChannel(ch)
channel = nil
}
}
private func handleInsert(_ action: InsertAction) {
// Den neuen Post aus dem Record dekodieren
guard
let id = action.record["id"]?.stringValue,
let content = action.record["content"]?.stringValue,
let createdAt = action.record["created_at"]?.stringValue
.flatMap({ ISO8601DateFormatter().date(from: $0) }),
let userId = action.record["user_id"]?.stringValue,
let isAnon = action.record["is_anonymous"]?.boolValue
else { return }
let moodString = action.record["mood"]?.stringValue
let mood = moodString.flatMap(Mood.init(rawValue:))
let post = Post(
id: id,
author: User.anonymousPlaceholder, // Profil wird lazily nachgeladen
content: content,
mood: mood,
createdAt: createdAt,
resonanceCount: 0,
hasResonated: false,
commentCount: 0,
isAnonymous: isAnon,
nightOf: createdAt
)
newPostsCount += 1
onNewPost?(post)
}
}
// MARK: - User placeholder für Realtime (Profil wird nachgeladen)
extension User {
static let anonymousPlaceholder = User(
id: "anonymous",
username: "anonym",
displayName: "anonym",
bio: nil, avatarURL: nil,
followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false
)
}
@@ -0,0 +1,379 @@
import Foundation
import Supabase
// MARK: - Supabase Client (Singleton)
let supabase = SupabaseClient(
supabaseURL: Config.supabaseURL,
supabaseKey: Config.supabaseAnonKey,
options: SupabaseClientOptions(
db: .init(
encoder: {
let e = JSONEncoder()
e.keyEncodingStrategy = .convertToSnakeCase
e.dateEncodingStrategy = .iso8601
return e
}(),
decoder: {
let d = JSONDecoder()
d.keyDecodingStrategy = .convertFromSnakeCase
d.dateDecodingStrategy = .iso8601
return d
}()
)
)
)
// MARK: - Auth
extension SupabaseClient {
func signUp(email: String, password: String, username: String, displayName: String) async throws {
try await self.auth.signUp(
email: email,
password: password,
data: [
"username": .string(username.lowercased()),
"display_name": .string(displayName)
]
)
}
func signIn(email: String, password: String) async throws {
try await self.auth.signIn(email: email, password: password)
}
/// Login mit Username: holt zuerst die E-Mail, dann normaler Sign-In
func signIn(username: String, password: String) async throws {
let email: String? = try await self
.rpc("get_email_by_username", params: ["p_username": username])
.execute()
.value
guard let email else { throw AuthError.usernameNotFound }
try await self.auth.signIn(email: email, password: password)
}
func signOut() async throws {
try await self.auth.signOut()
}
/// Account vollständig löschen (DSGVO löscht alles über DB-Funktion)
func deleteAccount() async throws {
try await self.rpc("delete_my_account").execute()
}
var currentUserId: UUID? {
try? self.auth.session.user.id
}
}
// MARK: - Profil
extension SupabaseClient {
func getMyProfile() async throws -> Profile {
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
return try await self
.from("profiles")
.select()
.eq("id", value: uid)
.single()
.execute()
.value
}
func getProfile(userId: UUID) async throws -> Profile {
try await self
.from("profiles")
.select()
.eq("id", value: userId)
.single()
.execute()
.value
}
func updateProfile(displayName: String? = nil, bio: String? = nil) async throws {
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
var update: [String: String] = [:]
if let n = displayName { update["display_name"] = n }
if let b = bio { update["bio"] = b }
guard !update.isEmpty else { return }
try await self.from("profiles").update(update).eq("id", value: uid).execute()
}
func savePushToken(_ token: String) async throws {
guard let uid = currentUserId else { return }
try await self.from("profiles")
.update(["push_token": token])
.eq("id", value: uid)
.execute()
}
func removePushToken() async throws {
guard let uid = currentUserId else { return }
try await self.from("profiles")
.update(["push_token": nil as String?])
.eq("id", value: uid)
.execute()
}
}
// MARK: - Posts
extension SupabaseClient {
/// Feed: Posts der letzten 14h von gefollowten Usern + eigene
func getFeed() async throws -> [Post] {
guard let uid = currentUserId else { return [] }
// Erst die gefolgten User-IDs holen
// Supabase gibt Objekte zurück [{following_id:"uuid"}], kein [String]
struct FollowRow: Decodable { let followingId: String }
let followRows: [FollowRow] = try await self
.from("follows")
.select("following_id")
.eq("follower_id", value: uid)
.execute()
.value
let allIds = followRows.map(\.followingId) + [uid.uuidString]
let rows: [FeedPostRow] = try await self
.from("feed_posts")
.select()
.in("author_id", values: allIds)
.order("created_at", ascending: false)
.limit(150)
.execute()
.value
// Eigene Resonances holen (RLS filtert, SDK gibt nur eigene zurück)
let myResonances: [ResonanceRow] = (try? await self
.from("resonances")
.select("post_id")
.eq("user_id", value: uid)
.execute()
.value) ?? []
let mySet = Set(myResonances.map(\.postId))
return rows.map { row in row.toPost(hasResonated: mySet.contains(row.id)) }
}
/// Persönliches Tagebuch: alle eigenen Posts, auch gelöschte (soft)
func getDiary() async throws -> [Post] {
guard let uid = currentUserId else { return [] }
let rows: [FeedPostRow] = try await self
.from("posts")
.select("id, content, mood, is_anonymous, created_at, resonance_count:resonances(count)")
.eq("user_id", value: uid)
.is("deleted_at", value: nil)
.order("created_at", ascending: false)
.limit(365)
.execute()
.value
return rows.map { $0.toPost(hasResonated: false) }
}
func getUserPosts(userId: UUID) async throws -> [Post] {
let rows: [FeedPostRow] = try await self
.from("feed_posts")
.select()
.eq("author_id", value: userId)
.order("created_at", ascending: false)
.limit(50)
.execute()
.value
return rows.map { $0.toPost(hasResonated: false) }
}
func createPost(content: String, mood: Mood, isAnonymous: Bool) async throws {
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
try await self.from("posts").insert([
"user_id": uid.uuidString,
"content": content,
"mood": mood.rawValue,
"is_anonymous": isAnonymous
]).execute()
}
func softDeletePost(id: String) async throws {
try await self.from("posts")
.update(["deleted_at": ISO8601DateFormatter().string(from: Date())])
.eq("id", value: id)
.execute()
}
}
// MARK: - Resonances
extension SupabaseClient {
func toggleResonance(postId: String, currentlyActive: Bool) async throws {
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
if currentlyActive {
try await self.from("resonances")
.delete()
.eq("post_id", value: postId)
.eq("user_id", value: uid)
.execute()
} else {
try await self.from("resonances")
.insert(["post_id": postId, "user_id": uid.uuidString])
.execute()
}
}
}
// MARK: - Follows
extension SupabaseClient {
func follow(userId: UUID) async throws {
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
try await self.from("follows")
.insert(["follower_id": uid.uuidString, "following_id": userId.uuidString])
.execute()
}
func unfollow(userId: UUID) async throws {
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
try await self.from("follows")
.delete()
.eq("follower_id", value: uid)
.eq("following_id", value: userId)
.execute()
}
func getStreak(userId: UUID) async throws -> Int {
// Nächte mit Posts berechnet in SQL
let rows: [[String: Int]] = (try? await self
.rpc("get_streak", params: ["p_user_id": userId.uuidString])
.execute()
.value) ?? []
return rows.first?["streak"] ?? 0
}
}
// MARK: - Reports
extension SupabaseClient {
func reportPost(postId: String, reason: String, details: String?) async throws {
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
try await self.from("reports").insert([
"post_id": postId,
"reporter_id": uid.uuidString,
"reason": reason,
"details": details as Any
]).execute()
}
}
// MARK: - Whispers
extension SupabaseClient {
func sendWhisper(toUserId: UUID, content: String, postId: String?) async throws {
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
try await self.from("whispers").insert([
"from_user_id": uid.uuidString,
"to_user_id": toUserId.uuidString,
"content": content,
"post_id": postId as Any
]).execute()
}
func getMyWhispers() async throws -> [Whisper] {
guard let uid = currentUserId else { return [] }
return try await self
.from("whispers")
.select("*, from_profile:profiles!from_user_id(username, display_name, avatar_url)")
.eq("to_user_id", value: uid)
.order("created_at", ascending: false)
.limit(50)
.execute()
.value
}
func markWhisperRead(id: UUID) async throws {
try await self.from("whispers")
.update(["read_at": ISO8601DateFormatter().string(from: Date())])
.eq("id", value: id)
.execute()
}
}
// MARK: - Errors
enum AuthError: LocalizedError {
case notAuthenticated
case usernameNotFound
var errorDescription: String? {
switch self {
case .notAuthenticated: return "Nicht angemeldet"
case .usernameNotFound: return "Benutzername nicht gefunden"
}
}
}
// MARK: - Row types (Supabase responses)
struct FeedPostRow: Decodable {
let id: String
let content: String
let mood: String?
let isAnonymous: Bool
let createdAt: Date
let resonanceCount: Int
// Autor (nil bei anonymen Posts die nicht von mir sind)
let authorId: String?
let authorUsername: String?
let authorDisplayName: String?
let authorAvatarUrl: String?
func toPost(hasResonated: Bool) -> Post {
let author: User? = authorId.map {
User(
id: $0,
username: authorUsername ?? "?",
displayName: authorDisplayName ?? "?",
bio: nil, avatarURL: authorAvatarUrl.flatMap(URL.init),
followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false
)
}
return Post(
id: id,
author: author ?? User.anonymousPlaceholder,
content: content,
mood: mood.flatMap(Mood.init(rawValue:)),
createdAt: createdAt,
resonanceCount: resonanceCount,
hasResonated: hasResonated,
commentCount: 0,
isAnonymous: isAnonymous,
nightOf: createdAt
)
}
}
struct ResonanceRow: Decodable { let postId: String }
struct Profile: Decodable {
let id: UUID
let username: String
let displayName: String
let bio: String?
let avatarUrl: String?
let isPro: Bool
let isAdmin: Bool
let createdAt: Date
}
struct Whisper: Identifiable, Decodable {
let id: UUID
let fromUserId: UUID
let content: String
let readAt: Date?
let createdAt: Date
}
@@ -0,0 +1,40 @@
import Foundation
@MainActor
class FeedViewModel: ObservableObject {
@Published var posts: [Post] = []
@Published var isLoading = false
func load() async {
isLoading = true
defer { isLoading = false }
do {
posts = try await supabase.getFeed()
} catch {
#if DEBUG
posts = Post.previews
#endif
}
}
func resonate(_ post: Post) async {
guard let idx = posts.firstIndex(where: { $0.id == post.id }) else { return }
let wasActive = posts[idx].hasResonated
posts[idx].hasResonated = !wasActive
posts[idx].resonanceCount += wasActive ? -1 : 1
do {
try await supabase.toggleResonance(postId: post.id, currentlyActive: wasActive)
} catch {
posts[idx].hasResonated = wasActive
posts[idx].resonanceCount += wasActive ? 1 : -1
}
}
/// Neuen Post vom Realtime-Service in den Feed einfügen
func prepend(_ post: Post) {
guard !posts.contains(where: { $0.id == post.id }) else { return }
posts.insert(post, at: 0)
}
}
@@ -0,0 +1,33 @@
import Foundation
@MainActor
class ProfileViewModel: ObservableObject {
@Published var posts: [Post] = []
@Published var streak: Int = 0
@Published var isLoading = false
let userId: UUID
init(userId: UUID) {
self.userId = userId
}
convenience init(userIdString: String) {
self.init(userId: UUID(uuidString: userIdString) ?? UUID())
}
func load() async {
isLoading = true
defer { isLoading = false }
do {
async let postsTask = supabase.getUserPosts(userId: userId)
async let streakTask = supabase.getStreak(userId: userId)
(posts, streak) = try await (postsTask, streakTask)
} catch {
#if DEBUG
posts = Post.previews
streak = 4
#endif
}
}
}
+320
View File
@@ -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"
}
}
+287
View File
@@ -0,0 +1,287 @@
import SwiftUI
/// Persönliches Tagebuch alle eigenen Posts, auch die anonymen.
/// Bleibt für immer. Das ist der Retention-Hook.
struct DiaryView: View {
@StateObject private var viewModel = DiaryViewModel()
@EnvironmentObject var appState: AppState
var body: some View {
NavigationStack {
ZStack {
Color.nightBase.ignoresSafeArea()
if viewModel.groupedPosts.isEmpty && !viewModel.isLoading {
DiaryEmptyView()
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
// "Vor genau einem Jahr" Memory
if let memory = viewModel.yearAgoPost {
MemoryBanner(post: memory)
.padding(.bottom, 4)
}
ForEach(viewModel.groupedPosts, id: \.nightLabel) { group in
// Nacht-Header
DiaryNightHeader(label: group.nightLabel, count: group.posts.count)
ForEach(group.posts) { post in
DiaryPostRow(post: post) {
Task { await viewModel.deletePost(post) }
}
Divider().background(Color.nightBorder).padding(.leading, 16)
}
}
Color.clear.frame(height: 100)
}
}
.refreshable { await viewModel.load() }
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
HStack(spacing: 7) {
Image(systemName: "book.closed.fill")
.foregroundColor(.nightPurple)
Text("tagebuch")
.font(.nightTitle(17))
.foregroundColor(.nightPrimary)
}
}
}
}
.task { await viewModel.load() }
}
}
// MARK: - Memory Banner
struct MemoryBanner: View {
let post: Post
@State private var show = true
var body: some View {
if show {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Image(systemName: "clock.arrow.circlepath")
.foregroundColor(.nightPurpleSoft)
.font(.system(size: 14))
Text("vor genau einem jahr")
.font(.nightLabel(12, weight: .semibold))
.foregroundColor(.nightPurpleSoft)
.kerning(0.5)
Spacer()
Button { withAnimation { show = false } } label: {
Image(systemName: "xmark")
.font(.system(size: 12))
.foregroundColor(.nightTertiary)
}
}
Text(post.content)
.font(.nightBody(15))
.foregroundColor(.nightPrimary.opacity(0.85))
.lineSpacing(4)
HStack(spacing: 5) {
if let mood = post.mood {
Text(mood.emoji).font(.nightMono(11))
Text(mood.label).font(.nightLabel(11))
}
Text("·")
Text(post.formattedTime)
.font(.nightMono(11))
}
.foregroundColor(.nightTertiary)
}
.padding(16)
.background(
ZStack {
RoundedRectangle(cornerRadius: 0)
.fill(Color.nightPurple.opacity(0.06))
RoundedRectangle(cornerRadius: 0)
.fill(
LinearGradient(
colors: [Color.nightPurple.opacity(0.08), .clear],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
)
.overlay(
Rectangle().fill(Color.nightBorder).frame(height: 1),
alignment: .bottom
)
}
}
}
// MARK: - Night Group Header
struct DiaryNightHeader: View {
let label: String
let count: Int
var body: some View {
HStack {
Text(label)
.font(.nightLabel(12, weight: .semibold))
.foregroundColor(.nightSecondary)
.kerning(0.5)
Text("· \(count) Gedanke\(count == 1 ? "" : "n")")
.font(.nightLabel(12))
.foregroundColor(.nightTertiary)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Color.nightSurface)
}
}
// MARK: - Diary Post Row
struct DiaryPostRow: View {
let post: Post
let onDelete: () -> Void
@State private var confirmDelete = false
var body: some View {
HStack(alignment: .top, spacing: 0) {
Rectangle()
.fill(post.mood?.color ?? Color.nightTertiary)
.frame(width: 2)
.padding(.vertical, 14)
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(post.formattedTime)
.font(.nightMono(12))
.foregroundColor(.nightTertiary)
if post.isAnonymous {
Text("· anonym")
.font(.nightLabel(11))
.foregroundColor(.nightTertiary)
.italic()
}
if let mood = post.mood {
Text("· \(mood.emoji) \(mood.label)")
.font(.nightLabel(11))
.foregroundColor(mood.color.opacity(0.7))
}
Spacer()
if post.resonanceCount > 0 {
HStack(spacing: 3) {
Image(systemName: "heart.fill")
.font(.system(size: 10))
Text("\(post.resonanceCount)")
.font(.nightLabel(11))
}
.foregroundColor(.nightRed.opacity(0.7))
}
}
Text(post.content)
.font(.nightBody(16))
.foregroundColor(.nightPrimary.opacity(0.88))
.lineSpacing(5)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 14)
.padding(.vertical, 14)
.contextMenu {
Button(role: .destructive) {
confirmDelete = true
} label: {
Label("Löschen", systemImage: "trash")
}
}
}
.confirmationDialog(
"Post löschen?",
isPresented: $confirmDelete,
titleVisibility: .visible
) {
Button("Löschen", role: .destructive) { onDelete() }
} message: {
Text("Der Post wird aus dem Feed entfernt, bleibt aber in deinem Tagebuch-Archiv.")
}
}
}
// MARK: - Empty State
struct DiaryEmptyView: View {
var body: some View {
VStack(spacing: 18) {
Image(systemName: "book.closed")
.font(.system(size: 48))
.foregroundColor(.nightTertiary)
Text("noch nichts hier")
.font(.nightTitle(18))
.foregroundColor(.nightPrimary)
Text("Deine Posts landen hier.\nIn einem Jahr kannst du nachlesen, was dich\nmitten in der Nacht beschäftigt hat.")
.font(.nightBody(15))
.foregroundColor(.nightSecondary)
.multilineTextAlignment(.center)
.lineSpacing(4)
}
.padding(40)
}
}
// MARK: - ViewModel
@MainActor
class DiaryViewModel: ObservableObject {
struct NightGroup { let nightLabel: String; let posts: [Post] }
@Published var groupedPosts: [NightGroup] = []
@Published var yearAgoPost: Post? = nil
@Published var isLoading = false
func load() async {
isLoading = true
defer { isLoading = false }
do {
let posts = try await supabase.getDiary()
// Gruppiere nach Nacht (Datum - 2h, damit 25 Uhr zur selben Nacht gehört)
let grouped = Dictionary(grouping: posts) { post -> String in
let nightDate = post.createdAt.addingTimeInterval(-2 * 3600)
let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateFormat = "EEEE, d. MMMM yyyy"
return f.string(from: nightDate)
}
groupedPosts = grouped
.sorted { $0.key > $1.key }
.map { NightGroup(nightLabel: $0.key, posts: $0.value) }
// Memory: Post von vor genau einem Jahr
let oneYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: Date())!
yearAgoPost = posts.first { post in
Calendar.current.isDate(post.createdAt, equalTo: oneYearAgo, toGranularity: .day)
}
} catch {
#if DEBUG
groupedPosts = [NightGroup(nightLabel: "gestern", posts: Post.previews)]
#endif
}
}
func deletePost(_ post: Post) async {
try? await supabase.softDeletePost(id: post.id)
await load()
}
}
+385
View File
@@ -0,0 +1,385 @@
import SwiftUI
// MARK: - Feed
struct FeedView: View {
@StateObject private var viewModel = FeedViewModel()
@EnvironmentObject var appState: AppState
var realtime: RealtimeService? = nil
// Gerade Jetzt = posts younger than 10 minutes
var rightNowPosts: [Post] { viewModel.posts.filter { $0.isRightNow } }
var nightPosts: [Post] { viewModel.posts }
var body: some View {
NavigationStack {
ZStack {
Color.nightBase.ignoresSafeArea()
if viewModel.posts.isEmpty && !viewModel.isLoading {
EmptyNightView(windowState: appState.windowState)
} else {
ScrollView {
LazyVStack(spacing: 0) {
// GERADE JETZT
// Nur sichtbar wenn: Fenster offen ODER du hast gerade gepostet
// UND es gibt Leute die gleichzeitig posten
if !rightNowPosts.isEmpty && appState.windowState == .posted {
RightNowSection(
posts: rightNowPosts,
onResonate: { post in
Task { await viewModel.resonate(post) }
}
)
.padding(.bottom, 2)
}
// TRENNLINIE MIT KONTEXT
NightContextBar(
windowState: appState.windowState,
totalCount: nightPosts.count,
liveCount: rightNowPosts.count
)
// HEUTE NACHT
// Alle Posts dieser Nacht, chronologisch
ForEach(nightPosts) { post in
PostRowView(post: post) {
Task { await viewModel.resonate(post) }
}
Divider()
.background(Color.nightBorder)
.padding(.leading, 16)
}
if viewModel.isLoading {
ProgressView()
.tint(.nightPurple)
.padding(40)
}
Color.clear.frame(height: 100)
}
}
.refreshable { await viewModel.load() }
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
NightlyWordmark()
}
}
}
.task {
await viewModel.load()
if let realtime {
await realtime.startListening { [weak viewModel] post in
viewModel?.prepend(post)
}
}
}
}
}
// MARK: - Wordmark
struct NightlyWordmark: View {
var body: some View {
HStack(spacing: 7) {
Text("")
.font(.system(size: 15))
.foregroundColor(.nightPurple)
Text("nightly")
.font(.system(size: 17, weight: .bold, design: .rounded))
.foregroundColor(.nightPrimary)
}
}
}
// MARK: - Gerade Jetzt Section
//
// WANN ERSCHEINT DAS?
// Du hast in den letzten 10 Minuten gepostet
// Und mindestens eine andere Person auch
//
// WAS IST DER UNTERSCHIED ZU "HEUTE NACHT"?
// Gerade Jetzt = buchstäblich gerade, gleichzeitig, diese Minute
// Heute Nacht = alle Posts seit dem Öffnen des Fensters
//
// ANALOGIE: Gerade Jetzt = du bist gerade im selben Raum wie jemand.
// Heute Nacht = der gesamte Raum-Verlauf dieser Nacht.
struct RightNowSection: View {
let posts: [Post]
let onResonate: (Post) -> Void
@State private var pulse = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack(spacing: 10) {
// Pulsierender grüner Punkt = LIVE
ZStack {
Circle()
.fill(Color.nightGreen.opacity(0.25))
.frame(width: 16, height: 16)
.scaleEffect(pulse ? 1.8 : 1.0)
.opacity(pulse ? 0 : 1)
Circle()
.fill(Color.nightGreen)
.frame(width: 7, height: 7)
}
.onAppear {
withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: false)) {
pulse = true
}
}
Text("gerade jetzt")
.font(.nightLabel(12, weight: .semibold))
.foregroundColor(.nightGreen)
.kerning(0.8)
Text("· \(posts.count) \(posts.count == 1 ? "Person" : "Personen") gleichzeitig wach")
.font(.nightLabel(12))
.foregroundColor(.nightSecondary)
Spacer()
// Info-Tooltip
HelpTooltip(
text: "Leute die in den letzten 10 Minuten gepostet haben — ihr seid buchstäblich gleichzeitig wach."
)
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 12)
// Horizontal Cards
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(posts) { post in
RightNowCard(post: post, onResonate: { onResonate(post) })
}
}
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
}
.background(
ZStack {
Color.nightSurface
Color.nightGreen.opacity(0.025)
}
)
.overlay(
Rectangle()
.fill(Color.nightGreen.opacity(0.15))
.frame(height: 1),
alignment: .bottom
)
}
}
struct RightNowCard: View {
let post: Post
let onResonate: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
if post.isAnonymous {
AnonymousAvatar(size: 26)
} else {
AvatarView(user: post.author, size: 26)
}
Text(post.isAnonymous ? "anonym" : "@\(post.author.username)")
.font(.nightLabel(12))
.foregroundColor(post.isAnonymous ? .nightSecondary : .nightPrimary)
.lineLimit(1)
Spacer()
if let mood = post.mood {
Text(mood.emoji)
.font(.nightMono(11))
.foregroundColor(mood.color.opacity(0.7))
}
}
Text(post.content)
.font(.nightBody(14))
.foregroundColor(.nightPrimary.opacity(0.88))
.lineSpacing(4)
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
Button(action: onResonate) {
HStack(spacing: 4) {
Image(systemName: post.hasResonated ? "heart.fill" : "heart")
.font(.system(size: 12))
.foregroundColor(post.hasResonated ? .nightRed : .nightSecondary)
if post.resonanceCount > 0 {
Text("\(post.resonanceCount)")
.font(.nightLabel(11))
.foregroundColor(.nightSecondary)
}
}
}
}
.padding(14)
.frame(width: 210, height: 148)
.background(
ZStack {
RoundedRectangle(cornerRadius: 14)
.fill(Color.nightRaised)
if let mood = post.mood {
RoundedRectangle(cornerRadius: 14)
.fill(
LinearGradient(
colors: [mood.color.opacity(0.07), .clear],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
RoundedRectangle(cornerRadius: 14)
.strokeBorder(Color.nightGreen.opacity(0.18), lineWidth: 1)
}
)
}
}
// MARK: - Context Bar (der Übergang zwischen Gerade Jetzt und Heute Nacht)
struct NightContextBar: View {
let windowState: AppState.WindowState
let totalCount: Int
let liveCount: Int
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text("heute nacht")
.font(.nightLabel(12, weight: .semibold))
.foregroundColor(.nightSecondary)
.kerning(0.5)
if totalCount > 0 {
Text("· \(totalCount) Gedanken")
.font(.nightLabel(12))
.foregroundColor(.nightTertiary)
}
}
Text(statusSubtitle)
.font(.nightLabel(11))
.foregroundColor(.nightTertiary)
}
Spacer()
// Window status pill
HStack(spacing: 5) {
Circle()
.fill(windowState == .open ? Color.nightGreen : Color.nightTertiary)
.frame(width: 6, height: 6)
Text(windowState == .open ? "offen" : "geschlossen")
.font(.nightLabel(11))
.foregroundColor(.nightSecondary)
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.nightRaised)
.clipShape(Capsule())
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.overlay(
Rectangle()
.fill(Color.nightBorder)
.frame(height: 1),
alignment: .bottom
)
}
var statusSubtitle: String {
switch windowState {
case .open: return "Du kannst noch posten — bis 05:00"
case .posted: return "Dein Post ist sichtbar bis morgen früh"
case .closed: return "Fenster öffnet später heute Nacht"
case .missed: return "Nächste Chance: heute Nacht"
}
}
}
// MARK: - Help Tooltip
struct HelpTooltip: View {
let text: String
@State private var show = false
var body: some View {
Button {
withAnimation(.spring(duration: 0.3)) { show.toggle() }
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
withAnimation { show = false }
}
} label: {
Image(systemName: "info.circle")
.font(.system(size: 13))
.foregroundColor(.nightTertiary)
}
.overlay(alignment: .topTrailing) {
if show {
Text(text)
.font(.nightBody(12))
.foregroundColor(.nightPrimary)
.padding(10)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.nightRaised)
.shadow(color: .black.opacity(0.4), radius: 8, y: 4)
)
.frame(width: 200)
.offset(x: -160, y: 28)
.transition(.opacity.combined(with: .scale(scale: 0.9, anchor: .topTrailing)))
.zIndex(100)
}
}
}
}
// MARK: - Empty State
struct EmptyNightView: View {
let windowState: AppState.WindowState
var body: some View {
VStack(spacing: 20) {
Text("")
.font(.system(size: 52))
.foregroundColor(.nightPurple.opacity(0.4))
VStack(spacing: 8) {
Text(windowState == .open ? "sei der erste heute nacht" : "noch ruhig hier")
.font(.nightTitle(19))
.foregroundColor(.nightPrimary)
Text(windowState == .open
? "Das Fenster ist offen.\nPoste einen Gedanken — andere sind auch wach."
: "Wenn dein Fenster öffnet, kannst du posten.\nErst dann siehst du alle anderen."
)
.font(.nightBody(15))
.foregroundColor(.nightSecondary)
.multilineTextAlignment(.center)
.lineSpacing(4)
}
}
.padding(40)
}
}
+232
View File
@@ -0,0 +1,232 @@
import SwiftUI
/// Impressum + Datenschutzerklärung
/// Muss vor dem Launch von einem Anwalt geprüft/ergänzt werden!
/// Kosten: ca. 300500 einmalig für Impressum + AGB + DSGVO-Datenschutzerklärung
struct LegalView: View {
@Environment(\.dismiss) var dismiss
@State private var tab = 0
var body: some View {
NavigationStack {
ZStack {
Color.nightBase.ignoresSafeArea()
VStack(spacing: 0) {
// Tab Switcher
HStack(spacing: 0) {
ForEach(["impressum", "datenschutz", "nutzungsbedingungen"], id: \.self) { label in
let idx = ["impressum", "datenschutz", "nutzungsbedingungen"].firstIndex(of: label)!
Button(label) { tab = idx }
.font(.nightLabel(12, weight: tab == idx ? .semibold : .regular))
.foregroundColor(tab == idx ? .nightPrimary : .nightSecondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.overlay(
Rectangle()
.fill(tab == idx ? Color.nightPurple : .clear)
.frame(height: 2),
alignment: .bottom
)
}
}
.padding(.horizontal, 16)
.overlay(Rectangle().fill(Color.nightBorder).frame(height: 1), alignment: .bottom)
ScrollView {
VStack(alignment: .leading, spacing: 0) {
switch tab {
case 0: ImpressumContent()
case 1: DatenschutzContent()
default: NutzungsbedingungenContent()
}
}
.padding(20)
}
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Schließen") { dismiss() }
.foregroundColor(.nightSecondary)
}
}
}
.preferredColorScheme(.dark)
}
}
// MARK: - Impressum
struct ImpressumContent: View {
var body: some View {
LegalSection(title: "Impressum") {
// PFLICHTANGABEN vor Launch ausfüllen!
LegalParagraph(title: "Angaben gemäß § 5 TMG") {
"""
[DEIN NAME]
[STRASSE HAUSNUMMER]
[PLZ ORT]
Deutschland
⚠️ Vor Launch ausfüllen!
"""
}
LegalParagraph(title: "Kontakt") {
"""
E-Mail: legal@xxx.dk0.dev
⚠️ Vor Launch ausfüllen!
"""
}
LegalParagraph(title: "Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV") {
"[DEIN NAME], [ADRESSE]"
}
LegalParagraph(title: "Hinweis") {
"""
Diese App ist ein privates Projekt. Für die Richtigkeit, \
Vollständigkeit und Aktualität der Inhalte kann keine Gewähr übernommen werden.
"""
}
}
}
}
// MARK: - Datenschutz
struct DatenschutzContent: View {
var body: some View {
LegalSection(title: "Datenschutzerklärung") {
LegalParagraph(title: "⚠️ Hinweis") {
"""
Diese Datenschutzerklärung ist ein Entwurf und muss vor dem Launch \
von einem Datenschutzanwalt geprüft und vervollständigt werden. \
Kosten: ca. 300500€.
"""
}
LegalParagraph(title: "Verantwortlicher") {
"[DEIN NAME], [ADRESSE], [E-MAIL]"
}
LegalParagraph(title: "Welche Daten wir speichern") {
"""
• E-Mail-Adresse (für Account & Passwort-Reset)
• Benutzername und Anzeigename
• Posts, Reaktionen, Kommentare (Inhalte die du selbst erstellst)
• Push-Token (für Benachrichtigungen, optional)
• IP-Adresse in Server-Logs (max. 14 Tage)
"""
}
LegalParagraph(title: "Wofür wir Daten verwenden") {
"""
• Betrieb des Dienstes (Authentifizierung, Feed, Benachrichtigungen)
• Moderation (Meldungen von Inhalten)
• Keine Weitergabe an Dritte außer für den Betrieb notwendige Dienste
"""
}
LegalParagraph(title: "Serverstandort") {
"Alle Daten werden auf Servern in der EU gespeichert."
}
LegalParagraph(title: "Deine Rechte (DSGVO)") {
"""
• Auskunft über gespeicherte Daten: legal@xxx.dk0.dev
• Berichtigung falscher Daten
• Löschung: Account in den Einstellungen löschen — entfernt alle deine Daten sofort
• Datenübertragbarkeit: auf Anfrage per E-Mail
• Widerspruch gegen Verarbeitung: legal@xxx.dk0.dev
• Beschwerde bei der Datenschutzbehörde
"""
}
LegalParagraph(title: "Datenlöschung") {
"""
Posts werden 14 Stunden nach Erstellung aus dem öffentlichen Feed entfernt. \
Dein persönliches Tagebuch behältst du so lange du möchtest. \
Account-Löschung entfernt alle Daten dauerhaft und unwiderruflich.
"""
}
LegalParagraph(title: "Cookies / Tracking") {
"Wir verwenden keine Cookies, keine Tracker, keine Werbenetze."
}
}
}
}
// MARK: - Nutzungsbedingungen
struct NutzungsbedingungenContent: View {
var body: some View {
LegalSection(title: "Nutzungsbedingungen") {
LegalParagraph(title: "⚠️ Entwurf") {
"Diese Nutzungsbedingungen sind ein Entwurf und müssen vor dem Launch von einem Anwalt geprüft werden."
}
LegalParagraph(title: "Nutzung") {
"""
nightly ist ein Dienst für Personen ab 17 Jahren. \
Du bist für die Inhalte die du postest selbst verantwortlich.
"""
}
LegalParagraph(title: "Verbotene Inhalte") {
"""
Folgende Inhalte sind verboten:
• Hassrede, Diskriminierung, Bedrohung
• Belästigung oder Mobbing
• Illegale Inhalte jeglicher Art
• Spam oder kommerzielle Werbung
• Inhalte die andere Personen ohne deren Zustimmung zeigen
"""
}
LegalParagraph(title: "Moderation") {
"""
Gemeldete Inhalte werden geprüft und können ohne Vorankündigung entfernt werden. \
Bei schwerwiegenden Verstößen behalten wir uns die Sperrung des Accounts vor.
"""
}
LegalParagraph(title: "Haftungsausschluss") {
"""
Wir übernehmen keine Haftung für nutzergenerierte Inhalte. \
Der Dienst wird ohne Gewähr für Verfügbarkeit bereitgestellt.
"""
}
}
}
}
// MARK: - Reusable components
struct LegalSection<Content: View>: View {
let title: String
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text(title)
.font(.nightTitle(22))
.foregroundColor(.nightPrimary)
.padding(.bottom, 4)
content
}
}
}
struct LegalParagraph: View {
let title: String
let body: String
init(title: String, _ body: () -> String) {
self.title = title
self.body = body()
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.nightLabel(14, weight: .semibold))
.foregroundColor(.nightPrimary)
Text(body)
.font(.nightBody(14))
.foregroundColor(.nightSecondary)
.lineSpacing(4)
.fixedSize(horizontal: false, vertical: true)
}
}
}
+199
View File
@@ -0,0 +1,199 @@
import SwiftUI
struct MainTabView: View {
@EnvironmentObject var appState: AppState
@StateObject private var realtime = RealtimeService()
@State private var selectedTab = 0
@State private var showCompose = false
@State private var showSettings = false
var body: some View {
ZStack(alignment: .bottom) {
Color.nightBase.ignoresSafeArea()
// Content
TabContent(
selectedTab: selectedTab,
realtime: realtime
)
.environmentObject(appState)
// Floating Tab Bar
FloatingTabBar(
selectedTab: $selectedTab,
windowState: appState.windowState,
onCompose: { showCompose = true },
onSettings: { showSettings = true }
)
}
.ignoresSafeArea(edges: .bottom)
.sheet(isPresented: $showCompose) {
ComposeView().environmentObject(appState)
}
.sheet(isPresented: $showSettings) {
SettingsView().environmentObject(appState)
}
.onDisappear {
Task { await realtime.stopListening() }
}
}
}
// MARK: - Tab Content
private struct TabContent: View {
let selectedTab: Int
@ObservedObject var realtime: RealtimeService
@EnvironmentObject var appState: AppState
var body: some View {
ZStack {
FeedView(realtime: realtime)
.environmentObject(appState)
.opacity(selectedTab == 0 ? 1 : 0)
.allowsHitTesting(selectedTab == 0)
DiaryView()
.environmentObject(appState)
.opacity(selectedTab == 1 ? 1 : 0)
.allowsHitTesting(selectedTab == 1)
ProfileView(
user: appState.currentUser ?? .preview,
isCurrentUser: true
)
.environmentObject(appState)
.opacity(selectedTab == 2 ? 1 : 0)
.allowsHitTesting(selectedTab == 2)
}
}
}
// MARK: - Floating Tab Bar
struct FloatingTabBar: View {
@Binding var selectedTab: Int
let windowState: AppState.WindowState
let onCompose: () -> Void
let onSettings: () -> Void
var body: some View {
HStack(spacing: 0) {
// Feed
TabIcon(icon: "moon.stars", activeIcon: "moon.stars.fill", isSelected: selectedTab == 0) {
selectedTab = 0
}
Spacer()
// Diary
TabIcon(icon: "book.closed", activeIcon: "book.closed.fill", isSelected: selectedTab == 1) {
selectedTab = 1
}
Spacer()
// Center: Compose
ComposeTabButton(windowState: windowState, onTap: onCompose)
Spacer()
// Profile
TabIcon(icon: "person", activeIcon: "person.fill", isSelected: selectedTab == 2) {
selectedTab = 2
}
Spacer()
// Settings
TabIcon(icon: "gearshape", activeIcon: "gearshape.fill", isSelected: false) {
onSettings()
}
}
.padding(.horizontal, 20)
.padding(.vertical, 10)
.padding(.bottom, 18)
.background(
Rectangle()
.fill(.ultraThinMaterial.opacity(0.8))
.background(Color.nightBase.opacity(0.85))
.ignoresSafeArea()
)
.overlay(
Rectangle().fill(Color.nightBorder).frame(height: 1),
alignment: .top
)
}
}
struct TabIcon: View {
let icon: String
let activeIcon: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: isSelected ? activeIcon : icon)
.font(.system(size: 21))
.foregroundColor(isSelected ? .nightPrimary : .nightSecondary)
.frame(width: 44, height: 44)
}
}
}
struct ComposeTabButton: View {
let windowState: AppState.WindowState
let onTap: () -> Void
@State private var glow = false
var body: some View {
Button {
guard windowState == .open else { return }
onTap()
} label: {
ZStack {
if windowState == .open {
Circle()
.fill(Color.nightPurple.opacity(0.18))
.frame(width: 62, height: 62)
.scaleEffect(glow ? 1.15 : 1.0)
.animation(.easeInOut(duration: 2.2).repeatForever(autoreverses: true), value: glow)
}
Circle()
.fill(buttonFill)
.frame(width: 50, height: 50)
Image(systemName: buttonIcon)
.font(.system(size: 19, weight: .semibold))
.foregroundColor(.white)
}
}
.onAppear { glow = true }
.animation(.easeInOut(duration: 0.4), value: windowState)
}
var buttonFill: AnyShapeStyle {
switch windowState {
case .open:
return AnyShapeStyle(LinearGradient(
colors: [Color(hex: "8B5CF6"), Color(hex: "6D28D9")],
startPoint: .topLeading, endPoint: .bottomTrailing
))
case .posted:
return AnyShapeStyle(LinearGradient(
colors: [Color(hex: "059669"), Color(hex: "047857")],
startPoint: .top, endPoint: .bottom
))
default:
return AnyShapeStyle(Color.nightRaised)
}
}
var buttonIcon: String {
switch windowState {
case .open: return "plus"
case .posted: return "checkmark"
default: return "moon.zzz"
}
}
}
@@ -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 }
}
}
}
+291
View File
@@ -0,0 +1,291 @@
import SwiftUI
// MARK: - Post Row
struct PostRowView: View {
let post: Post
let onResonate: () -> Void
var onReport: (() -> Void)? = nil
@State private var showReport = false
var body: some View {
HStack(alignment: .top, spacing: 0) {
// Mood accent bar der einzige echte Farbakzent im Feed
Rectangle()
.fill(post.mood?.color ?? Color.nightTertiary)
.frame(width: 2)
.padding(.vertical, 18)
VStack(alignment: .leading, spacing: 11) {
// Author
HStack(spacing: 9) {
if post.isAnonymous {
AnonymousAvatar(size: 32)
} else {
AvatarView(user: post.author, size: 32)
}
VStack(alignment: .leading, spacing: 1) {
if post.isAnonymous {
Text("anonym")
.font(.nightLabel(13))
.foregroundColor(.nightSecondary)
.italic()
} else {
Text(post.author.displayName)
.font(.nightLabel(14, weight: .semibold))
.foregroundColor(.nightPrimary)
}
}
Spacer()
HStack(spacing: 8) {
if let mood = post.mood {
Text(mood.emoji)
.font(.nightMono(11))
.foregroundColor(mood.color.opacity(0.8))
}
Text(post.formattedTime)
.font(.nightMono(11))
.foregroundColor(.nightTertiary)
// Drei-Punkte-Menü für Report
Menu {
Button(role: .destructive) {
showReport = true
} label: {
Label("Melden", systemImage: "flag")
}
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 13))
.foregroundColor(.nightTertiary)
.padding(4)
}
}
}
// Content
Text(post.content)
.font(.nightBody(16))
.foregroundColor(.nightPrimary.opacity(0.9))
.lineSpacing(5)
.fixedSize(horizontal: false, vertical: true)
// Resonance
ResonanceButton(
count: post.resonanceCount,
isActive: post.hasResonated,
action: onResonate
)
}
.padding(.leading, 14)
.padding(.trailing, 16)
.padding(.vertical, 16)
}
.sheet(isPresented: $showReport) {
ReportSheet(postId: post.id)
}
}
}
// MARK: - Resonance Button
struct ResonanceButton: View {
let count: Int
let isActive: Bool
let action: () -> Void
@State private var scale: CGFloat = 1.0
var body: some View {
Button {
withAnimation(.spring(duration: 0.25, bounce: 0.7)) { scale = 1.4 }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation(.spring(duration: 0.2)) { scale = 1.0 }
}
action()
} label: {
HStack(spacing: 5) {
Image(systemName: isActive ? "heart.fill" : "heart")
.font(.system(size: 14))
.foregroundColor(isActive ? .nightRed : .nightSecondary)
.scaleEffect(scale)
Text(count > 0 ? "\(count)" : "hat mich getroffen")
.font(.nightLabel(13))
.foregroundColor(isActive ? .nightRed : .nightSecondary)
}
.padding(.vertical, 5)
.padding(.horizontal, count > 0 || isActive ? 10 : 0)
.background(
Capsule()
.fill(isActive ? Color.nightRed.opacity(0.1) : Color.clear)
)
}
.animation(.easeInOut(duration: 0.2), value: isActive)
}
}
// MARK: - Avatar
struct AvatarView: View {
let user: User
let size: CGFloat
var body: some View {
Group {
if let url = user.avatarURL {
AsyncImage(url: url) { img in img.resizable().scaledToFill() }
placeholder: { initials }
} else { initials }
}
.frame(width: size, height: size)
.clipShape(Circle())
}
var initials: some View {
ZStack {
Circle().fill(Color.nightPurple.opacity(0.18))
Text(String(user.displayName.prefix(1)).uppercased())
.font(.system(size: size * 0.38, weight: .semibold))
.foregroundColor(.nightPurpleSoft)
}
}
}
struct AnonymousAvatar: View {
let size: CGFloat
var body: some View {
ZStack {
Circle().fill(Color.nightRaised)
Image(systemName: "questionmark")
.font(.system(size: size * 0.35, weight: .semibold))
.foregroundColor(.nightSecondary)
}
.frame(width: size, height: size)
}
}
// MARK: - Report Sheet
struct ReportSheet: View {
let postId: String
@Environment(\.dismiss) var dismiss
@State private var selected: ReportReason? = nil
@State private var submitted = false
@State private var isLoading = false
enum ReportReason: String, CaseIterable {
case hate = "Hassrede / Diskriminierung"
case harassment = "Belästigung / Mobbing"
case selfharm = "Selbstverletzung / Suizid"
case illegal = "Illegale Inhalte"
case spam = "Spam"
case other = "Sonstiges"
}
var body: some View {
NavigationStack {
ZStack {
Color.nightSurface.ignoresSafeArea()
if submitted {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 48))
.foregroundColor(.nightGreen)
Text("Danke für deine Meldung")
.font(.nightTitle(18))
.foregroundColor(.nightPrimary)
Text("Wir prüfen den Inhalt so schnell wie möglich.")
.font(.nightBody(14))
.foregroundColor(.nightSecondary)
.multilineTextAlignment(.center)
Button("Schließen") { dismiss() }
.foregroundColor(.nightPurpleSoft)
.padding(.top, 8)
}
.padding(40)
} else {
VStack(alignment: .leading, spacing: 0) {
Text("Warum möchtest du das melden?")
.font(.nightTitle(17))
.foregroundColor(.nightPrimary)
.padding(.horizontal, 20)
.padding(.top, 24)
.padding(.bottom, 16)
ForEach(ReportReason.allCases, id: \.self) { reason in
Button {
selected = reason
} label: {
HStack {
Text(reason.rawValue)
.font(.nightBody(15))
.foregroundColor(.nightPrimary)
Spacer()
if selected == reason {
Image(systemName: "checkmark")
.foregroundColor(.nightPurpleSoft)
}
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(selected == reason ? Color.nightPurple.opacity(0.08) : Color.clear)
}
Divider().background(Color.nightBorder)
}
Spacer()
Button {
guard let reason = selected else { return }
Task { await submit(reason: reason) }
} label: {
Group {
if isLoading {
ProgressView().tint(.black)
} else {
Text("Melden")
.font(.nightLabel(16, weight: .semibold))
.foregroundColor(.black)
}
}
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(selected != nil ? Color.nightPrimary : Color.nightRaised)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(selected == nil || isLoading)
.padding(.horizontal, 20)
.padding(.bottom, 32)
}
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Abbrechen") { dismiss() }
.foregroundColor(.nightSecondary)
}
}
}
.presentationDetents([.medium])
.preferredColorScheme(.dark)
}
func submit(reason: ReportReason) async {
isLoading = true
defer { isLoading = false }
do {
try await supabase.reportPost(postId: postId, reason: reason.rawValue, details: nil)
submitted = true
} catch {
// Fehler still ignorieren Meldung trotzdem als abgeschlossen zeigen
submitted = true
}
}
}
+214
View File
@@ -0,0 +1,214 @@
import SwiftUI
struct ProfileView: View {
let user: User
let isCurrentUser: Bool
@EnvironmentObject var appState: AppState
@StateObject private var viewModel: ProfileViewModel
@State private var showSettings = false
init(user: User, isCurrentUser: Bool) {
self.user = user
self.isCurrentUser = isCurrentUser
_viewModel = StateObject(wrappedValue: ProfileViewModel(userIdString: user.id))
}
var body: some View {
NavigationStack {
ZStack {
Color.nightBase.ignoresSafeArea()
ScrollView {
VStack(spacing: 0) {
ProfileHeader(
user: user,
streak: viewModel.streak,
isCurrentUser: isCurrentUser
)
Divider().background(Color.nightBorder)
// Posts
if viewModel.isLoading {
ProgressView().tint(.nightPurple).padding(40)
} else if viewModel.posts.isEmpty {
EmptyProfilePosts()
} else {
LazyVStack(spacing: 0) {
ForEach(viewModel.posts) { post in
PostRowView(post: post) {}
Divider().background(Color.nightBorder).padding(.leading, 16)
}
}
}
Color.clear.frame(height: 100)
}
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if isCurrentUser {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showSettings = true
} label: {
Image(systemName: "gearshape")
.foregroundColor(.nightSecondary)
}
}
}
}
.sheet(isPresented: $showSettings) {
SettingsView().environmentObject(appState)
}
}
.task { await viewModel.load() }
}
}
// MARK: - Profile Header
struct ProfileHeader: View {
let user: User
let streak: Int
let isCurrentUser: Bool
@State private var isFollowing: Bool
@State private var isFollowLoading = false
init(user: User, streak: Int, isCurrentUser: Bool) {
self.user = user
self.streak = streak
self.isCurrentUser = isCurrentUser
_isFollowing = State(initialValue: user.isFollowing)
}
var body: some View {
VStack(spacing: 0) {
VStack(spacing: 16) {
AvatarView(user: user, size: 76)
VStack(spacing: 4) {
Text(user.displayName)
.font(.nightTitle(22))
.foregroundColor(.nightPrimary)
Text("@\(user.username)")
.font(.nightLabel(14))
.foregroundColor(.nightSecondary)
if let bio = user.bio {
Text(bio)
.font(.nightBody(14))
.foregroundColor(.nightPrimary.opacity(0.75))
.multilineTextAlignment(.center)
.padding(.top, 4)
}
}
// Stats
HStack(spacing: 36) {
ProfileStat(value: user.postCount, label: "nächte")
ProfileStat(value: user.followerCount, label: "follower")
ProfileStat(value: user.followingCount, label: "following")
}
// Streak
if streak > 0 {
HStack(spacing: 6) {
Image(systemName: streak >= 7 ? "flame.fill" : "flame")
.foregroundColor(streak >= 7 ? .orange : .nightSecondary)
Text("\(streak) Nächte in Folge")
.font(.nightLabel(13, weight: streak >= 7 ? .semibold : .regular))
.foregroundColor(streak >= 7 ? .nightPrimary : .nightSecondary)
}
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(Color.nightRaised)
.clipShape(Capsule())
}
// Action button
if isCurrentUser {
Button("profil bearbeiten") {}
.font(.nightLabel(14, weight: .medium))
.foregroundColor(.nightPrimary)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.nightRaised)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color.nightBorder, lineWidth: 1)
)
.padding(.horizontal, 48)
} else {
Button {
Task { await toggleFollow() }
} label: {
Group {
if isFollowLoading { ProgressView().tint(isFollowing ? .nightPrimary : .nightBase) }
else {
Text(isFollowing ? "entfolgen" : "folgen")
.font(.nightLabel(14, weight: .semibold))
.foregroundColor(isFollowing ? .nightPrimary : .nightBase)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(isFollowing ? Color.nightRaised : Color.nightPrimary)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.disabled(isFollowLoading)
.padding(.horizontal, 48)
}
}
.padding(.horizontal, 20)
.padding(.top, 28)
.padding(.bottom, 20)
}
}
func toggleFollow() async {
isFollowLoading = true
defer { isFollowLoading = false }
guard let uid = UUID(uuidString: user.id) else { return }
do {
if isFollowing {
try await supabase.unfollow(userId: uid)
} else {
try await supabase.follow(userId: uid)
}
isFollowing.toggle()
} catch { /* handle error */ }
}
}
struct ProfileStat: View {
let value: Int
let label: String
var body: some View {
VStack(spacing: 3) {
Text("\(value)")
.font(.nightTitle(18))
.foregroundColor(.nightPrimary)
Text(label)
.font(.nightLabel(12))
.foregroundColor(.nightSecondary)
}
}
}
struct EmptyProfilePosts: View {
var body: some View {
VStack(spacing: 14) {
Image(systemName: "moon.zzz")
.font(.system(size: 36))
.foregroundColor(.nightTertiary)
Text("noch keine nächte")
.font(.nightLabel(15))
.foregroundColor(.nightSecondary)
}
.padding(.top, 60)
}
}
+16
View File
@@ -0,0 +1,16 @@
import SwiftUI
struct RootView: View {
@EnvironmentObject var appState: AppState
var body: some View {
Group {
if appState.isAuthenticated {
MainTabView()
} else {
OnboardingView()
}
}
.animation(.easeInOut(duration: 0.3), value: appState.isAuthenticated)
}
}
+243
View File
@@ -0,0 +1,243 @@
import SwiftUI
struct SettingsView: View {
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) var dismiss
@State private var showLegal = false
@State private var showDeleteConfirm = false
@State private var showDeleteFinal = false
@State private var deletePassword = ""
@State private var isDeleting = false
@State private var deleteError: String?
@State private var notificationsEnabled = false
var body: some View {
NavigationStack {
ZStack {
Color.nightBase.ignoresSafeArea()
List {
// Account
Section {
if let user = appState.currentUser {
HStack(spacing: 12) {
AvatarView(user: user, size: 44)
VStack(alignment: .leading, spacing: 2) {
Text(user.displayName)
.font(.nightLabel(15, weight: .semibold))
.foregroundColor(.nightPrimary)
Text("@\(user.username)")
.font(.nightLabel(13))
.foregroundColor(.nightSecondary)
}
}
.padding(.vertical, 4)
}
}
.listRowBackground(Color.nightSurface)
// Benachrichtigungen
Section("benachrichtigungen") {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("nightly ping")
.font(.nightLabel(15))
.foregroundColor(.nightPrimary)
Text("Wenn das Fenster öffnet")
.font(.nightLabel(12))
.foregroundColor(.nightSecondary)
}
Spacer()
Toggle("", isOn: $notificationsEnabled)
.tint(.nightPurple)
}
// APNs-Hinweis
HStack(spacing: 8) {
Image(systemName: "info.circle")
.foregroundColor(.nightSecondary)
.font(.system(size: 13))
Text("Push-Benachrichtigungen benötigen einen bezahlten Apple Developer Account ($99/Jahr).")
.font(.nightLabel(12))
.foregroundColor(.nightSecondary)
.lineSpacing(3)
}
.padding(.vertical, 2)
}
.listRowBackground(Color.nightSurface)
// Rechtliches
Section("rechtliches") {
Button {
showLegal = true
} label: {
HStack {
Text("Impressum & Datenschutz")
.font(.nightLabel(15))
.foregroundColor(.nightPrimary)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundColor(.nightSecondary)
}
}
HStack {
Text("Version")
.font(.nightLabel(15))
.foregroundColor(.nightPrimary)
Spacer()
Text(appVersion)
.font(.nightMono(13))
.foregroundColor(.nightSecondary)
}
}
.listRowBackground(Color.nightSurface)
// Account-Aktionen
Section("account") {
Button {
appState.signOut()
dismiss()
} label: {
Text("abmelden")
.font(.nightLabel(15))
.foregroundColor(.nightPrimary)
}
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
Text("account löschen")
.font(.nightLabel(15))
.foregroundColor(.nightRed)
}
}
.listRowBackground(Color.nightSurface)
}
.scrollContentBackground(.hidden)
.listStyle(.insetGrouped)
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Text("einstellungen")
.font(.nightTitle(17))
.foregroundColor(.nightPrimary)
}
ToolbarItem(placement: .navigationBarLeading) {
Button("Fertig") { dismiss() }
.foregroundColor(.nightSecondary)
}
}
.sheet(isPresented: $showLegal) { LegalView() }
}
.preferredColorScheme(.dark)
// Schritt 1: Erklärung
.confirmationDialog(
"Account wirklich löschen?",
isPresented: $showDeleteConfirm,
titleVisibility: .visible
) {
Button("Ja, Account löschen", role: .destructive) {
showDeleteFinal = true
}
} message: {
Text("Alle deine Posts, Reaktionen und Daten werden sofort und dauerhaft gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden.")
}
// Schritt 2: Passwort bestätigen
.sheet(isPresented: $showDeleteFinal) {
DeleteAccountSheet(
password: $deletePassword,
isDeleting: isDeleting,
error: deleteError,
onDelete: { Task { await deleteAccount() } }
)
}
.onAppear { checkNotificationStatus() }
}
var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
}
func checkNotificationStatus() {
Task {
let settings = await UNUserNotificationCenter.current().notificationSettings()
notificationsEnabled = settings.authorizationStatus == .authorized
}
}
func deleteAccount() async {
isDeleting = true
deleteError = nil
defer { isDeleting = false }
do {
try await appState.deleteAccount()
showDeleteFinal = false
dismiss()
} catch {
deleteError = error.localizedDescription
}
}
}
struct DeleteAccountSheet: View {
@Binding var password: String
let isDeleting: Bool
let error: String?
let onDelete: () -> Void
@Environment(\.dismiss) var dismiss
var body: some View {
NavigationStack {
ZStack {
Color.nightBase.ignoresSafeArea()
VStack(spacing: 24) {
Image(systemName: "trash.circle.fill")
.font(.system(size: 52))
.foregroundColor(.nightRed)
VStack(spacing: 8) {
Text("Account löschen")
.font(.nightTitle(22))
.foregroundColor(.nightPrimary)
Text("Diese Aktion löscht alle deine Daten dauerhaft. Kein Weg zurück.")
.font(.nightBody(15))
.foregroundColor(.nightSecondary)
.multilineTextAlignment(.center)
}
NightlyField(text: $password, placeholder: "passwort bestätigen", icon: "lock", isSecure: true)
.padding(.horizontal, 24)
if let err = error {
Text(err).font(.nightLabel(13)).foregroundColor(.nightRed)
}
Button {
onDelete()
} label: {
Group {
if isDeleting { ProgressView().tint(.white) }
else { Text("endgültig löschen").font(.nightLabel(16, weight: .semibold)).foregroundColor(.white) }
}
.frame(maxWidth: .infinity).frame(height: 50)
.background(Color.nightRed)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(password.isEmpty || isDeleting)
.padding(.horizontal, 24)
}
.padding(.top, 32)
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Abbrechen") { dismiss() }.foregroundColor(.nightSecondary)
}
}
}
.preferredColorScheme(.dark)
.presentationDetents([.medium])
}
}