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])
}
}
@@ -0,0 +1,636 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
95C8F89B2F9AC1BB00CA5386 /* Supabase in Frameworks */ = {isa = PBXBuildFile; productRef = 95C8F89A2F9AC1BB00CA5386 /* Supabase */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
95576B582F98D4200029BE54 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 95576B3E2F98D41F0029BE54 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 95576B452F98D41F0029BE54;
remoteInfo = thoughts;
};
95576B622F98D4200029BE54 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 95576B3E2F98D41F0029BE54 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 95576B452F98D41F0029BE54;
remoteInfo = thoughts;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
95576B462F98D41F0029BE54 /* thoughts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = thoughts.app; sourceTree = BUILT_PRODUCTS_DIR; };
95576B572F98D4200029BE54 /* thoughtsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = thoughtsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
95576B612F98D4200029BE54 /* thoughtsUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = thoughtsUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
95576B692F98D4200029BE54 /* Exceptions for "thoughts" folder in "thoughts" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 95576B452F98D41F0029BE54 /* thoughts */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
95576B482F98D41F0029BE54 /* thoughts */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
95576B692F98D4200029BE54 /* Exceptions for "thoughts" folder in "thoughts" target */,
);
path = thoughts;
sourceTree = "<group>";
};
95576B5A2F98D4200029BE54 /* thoughtsTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = thoughtsTests;
sourceTree = "<group>";
};
95576B642F98D4200029BE54 /* thoughtsUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = thoughtsUITests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
95576B432F98D41F0029BE54 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
95C8F89B2F9AC1BB00CA5386 /* Supabase in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
95576B542F98D4200029BE54 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
95576B5E2F98D4200029BE54 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
95576B3D2F98D41F0029BE54 = {
isa = PBXGroup;
children = (
95576B482F98D41F0029BE54 /* thoughts */,
95576B5A2F98D4200029BE54 /* thoughtsTests */,
95576B642F98D4200029BE54 /* thoughtsUITests */,
95C8F8992F9AC1BB00CA5386 /* Frameworks */,
95576B472F98D41F0029BE54 /* Products */,
);
sourceTree = "<group>";
};
95576B472F98D41F0029BE54 /* Products */ = {
isa = PBXGroup;
children = (
95576B462F98D41F0029BE54 /* thoughts.app */,
95576B572F98D4200029BE54 /* thoughtsTests.xctest */,
95576B612F98D4200029BE54 /* thoughtsUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
95C8F8992F9AC1BB00CA5386 /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
95576B452F98D41F0029BE54 /* thoughts */ = {
isa = PBXNativeTarget;
buildConfigurationList = 95576B6A2F98D4200029BE54 /* Build configuration list for PBXNativeTarget "thoughts" */;
buildPhases = (
95576B422F98D41F0029BE54 /* Sources */,
95576B432F98D41F0029BE54 /* Frameworks */,
95576B442F98D41F0029BE54 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
95576B482F98D41F0029BE54 /* thoughts */,
);
name = thoughts;
packageProductDependencies = (
95C8F89A2F9AC1BB00CA5386 /* Supabase */,
);
productName = thoughts;
productReference = 95576B462F98D41F0029BE54 /* thoughts.app */;
productType = "com.apple.product-type.application";
};
95576B562F98D4200029BE54 /* thoughtsTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 95576B6F2F98D4200029BE54 /* Build configuration list for PBXNativeTarget "thoughtsTests" */;
buildPhases = (
95576B532F98D4200029BE54 /* Sources */,
95576B542F98D4200029BE54 /* Frameworks */,
95576B552F98D4200029BE54 /* Resources */,
);
buildRules = (
);
dependencies = (
95576B592F98D4200029BE54 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
95576B5A2F98D4200029BE54 /* thoughtsTests */,
);
name = thoughtsTests;
packageProductDependencies = (
);
productName = thoughtsTests;
productReference = 95576B572F98D4200029BE54 /* thoughtsTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
95576B602F98D4200029BE54 /* thoughtsUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 95576B722F98D4200029BE54 /* Build configuration list for PBXNativeTarget "thoughtsUITests" */;
buildPhases = (
95576B5D2F98D4200029BE54 /* Sources */,
95576B5E2F98D4200029BE54 /* Frameworks */,
95576B5F2F98D4200029BE54 /* Resources */,
);
buildRules = (
);
dependencies = (
95576B632F98D4200029BE54 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
95576B642F98D4200029BE54 /* thoughtsUITests */,
);
name = thoughtsUITests;
packageProductDependencies = (
);
productName = thoughtsUITests;
productReference = 95576B612F98D4200029BE54 /* thoughtsUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
95576B3E2F98D41F0029BE54 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2640;
LastUpgradeCheck = 2640;
TargetAttributes = {
95576B452F98D41F0029BE54 = {
CreatedOnToolsVersion = 26.4.1;
};
95576B562F98D4200029BE54 = {
CreatedOnToolsVersion = 26.4.1;
TestTargetID = 95576B452F98D41F0029BE54;
};
95576B602F98D4200029BE54 = {
CreatedOnToolsVersion = 26.4.1;
TestTargetID = 95576B452F98D41F0029BE54;
};
};
};
buildConfigurationList = 95576B412F98D41F0029BE54 /* Build configuration list for PBXProject "thoughts" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 95576B3D2F98D41F0029BE54;
minimizedProjectReferenceProxies = 1;
packageReferences = (
95C8F8962F9ABFD100CA5386 /* XCRemoteSwiftPackageReference "supabase-swift" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 95576B472F98D41F0029BE54 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
95576B452F98D41F0029BE54 /* thoughts */,
95576B562F98D4200029BE54 /* thoughtsTests */,
95576B602F98D4200029BE54 /* thoughtsUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
95576B442F98D41F0029BE54 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
95576B552F98D4200029BE54 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
95576B5F2F98D4200029BE54 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
95576B422F98D41F0029BE54 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
95576B532F98D4200029BE54 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
95576B5D2F98D4200029BE54 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
95576B592F98D4200029BE54 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 95576B452F98D41F0029BE54 /* thoughts */;
targetProxy = 95576B582F98D4200029BE54 /* PBXContainerItemProxy */;
};
95576B632F98D4200029BE54 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 95576B452F98D41F0029BE54 /* thoughts */;
targetProxy = 95576B622F98D4200029BE54 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
95576B6B2F98D4200029BE54 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = thoughts/thoughts.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 8B9UP2YV66;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = thoughts/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dk0.dev.thoughts;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
95576B6C2F98D4200029BE54 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = thoughts/thoughts.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 8B9UP2YV66;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = thoughts/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dk0.dev.thoughts;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
95576B6D2F98D4200029BE54 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 8B9UP2YV66;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.4;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
95576B6E2F98D4200029BE54 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 8B9UP2YV66;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.4;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
95576B702F98D4200029BE54 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 8B9UP2YV66;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.4;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dk0.dev.thoughtsTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/thoughts.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/thoughts";
};
name = Debug;
};
95576B712F98D4200029BE54 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 8B9UP2YV66;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.4;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dk0.dev.thoughtsTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/thoughts.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/thoughts";
};
name = Release;
};
95576B732F98D4200029BE54 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 8B9UP2YV66;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dk0.dev.thoughtsUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = thoughts;
};
name = Debug;
};
95576B742F98D4200029BE54 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 8B9UP2YV66;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dk0.dev.thoughtsUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = thoughts;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
95576B412F98D41F0029BE54 /* Build configuration list for PBXProject "thoughts" */ = {
isa = XCConfigurationList;
buildConfigurations = (
95576B6D2F98D4200029BE54 /* Debug */,
95576B6E2F98D4200029BE54 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
95576B6A2F98D4200029BE54 /* Build configuration list for PBXNativeTarget "thoughts" */ = {
isa = XCConfigurationList;
buildConfigurations = (
95576B6B2F98D4200029BE54 /* Debug */,
95576B6C2F98D4200029BE54 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
95576B6F2F98D4200029BE54 /* Build configuration list for PBXNativeTarget "thoughtsTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
95576B702F98D4200029BE54 /* Debug */,
95576B712F98D4200029BE54 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
95576B722F98D4200029BE54 /* Build configuration list for PBXNativeTarget "thoughtsUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
95576B732F98D4200029BE54 /* Debug */,
95576B742F98D4200029BE54 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
95C8F8962F9ABFD100CA5386 /* XCRemoteSwiftPackageReference "supabase-swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/supabase/supabase-swift";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.5.1;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
95C8F89A2F9AC1BB00CA5386 /* Supabase */ = {
isa = XCSwiftPackageProductDependency;
package = 95C8F8962F9ABFD100CA5386 /* XCRemoteSwiftPackageReference "supabase-swift" */;
productName = Supabase;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 95576B3E2F98D41F0029BE54 /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
@@ -0,0 +1,69 @@
{
"originHash" : "5f3436049b395fcbc71828c07d82e81a46af698ebe0b146cdc52345a5a60558d",
"pins" : [
{
"identity" : "supabase-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/supabase/supabase-swift",
"state" : {
"revision" : "06ae7b34ec21406cbd3e643bee7a8a54206fa8f5",
"version" : "2.44.1"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab",
"version" : "1.7.0"
}
},
{
"identity" : "swift-clocks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-clocks",
"state" : {
"revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
"version" : "1.0.6"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
"version" : "1.3.2"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1",
"version" : "4.5.0"
}
},
{
"identity" : "swift-http-types",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-types.git",
"state" : {
"revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca",
"version" : "1.5.1"
}
},
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : {
"revision" : "dfd70507def84cb5fb821278448a262c6ff2bbad",
"version" : "1.9.0"
}
}
],
"version" : 3
}
+135
View File
@@ -0,0 +1,135 @@
import Combine
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 {
#if DEBUG
// Auto-Login mit Dev-Account in Debug-Builds
do {
try await signIn(email: DevCredentials.email, password: DevCredentials.password)
print("[DEBUG] Auto-Login erfolgreich")
return
} catch {
print("[DEBUG] Auto-Login fehlgeschlagen: \(error.localizedDescription)")
}
#endif
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() }
}
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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)
}
}
@@ -0,0 +1,30 @@
import UIKit
/// Zentrales Haptic-Feedback für die gesamte App.
enum Haptics {
private static let lightImpact = UIImpactFeedbackGenerator(style: .light)
private static let mediumImpact = UIImpactFeedbackGenerator(style: .medium)
private static let selection = UISelectionFeedbackGenerator()
private static let notification = UINotificationFeedbackGenerator()
/// Leichtes Feedback Resonance-Button, Follow
static func light() { lightImpact.impactOccurred() }
/// Mittleres Feedback Abmelden, wichtige Aktionen
static func medium() { mediumImpact.impactOccurred() }
/// Ganz sanftes Feedback Mood-Auswahl
static func soft() { lightImpact.impactOccurred(intensity: 0.45) }
/// Selection-Feedback Tab-Wechsel, Toggles
static func select() { selection.selectionChanged() }
/// Erfolg Post gesendet, Follow erfolgreich
static func success() { notification.notificationOccurred(.success) }
/// Warnung Account löschen
static func warning() { notification.notificationOccurred(.warning) }
/// Fehler Post fehlgeschlagen
static func error() { notification.notificationOccurred(.error) }
}
@@ -0,0 +1,138 @@
import SwiftUI
// MARK: - Shimmer Modifier
/// Gleitender Lichteffekt über Skeleton-Elemente.
struct ShimmerModifier: ViewModifier {
@State private var phase: CGFloat = -1
func body(content: Content) -> some View {
content
.overlay(
GeometryReader { geo in
LinearGradient(
colors: [
.clear,
Color.white.opacity(0.04),
Color.white.opacity(0.08),
Color.white.opacity(0.04),
.clear
],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: geo.size.width * 1.5)
.offset(x: phase * geo.size.width * 1.5)
}
.clipped()
)
.onAppear {
withAnimation(.linear(duration: 1.6).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
}
extension View {
func shimmer() -> some View {
modifier(ShimmerModifier())
}
}
// MARK: - Skeleton Post Row
/// Platzhalter-Zeile die aussieht wie ein echter Post.
struct SkeletonPostRow: View {
var body: some View {
HStack(alignment: .top, spacing: 0) {
// Mood-Accent-Bar
RoundedRectangle(cornerRadius: 1)
.fill(Color.nightTertiary.opacity(0.2))
.frame(width: 2)
.padding(.vertical, 18)
VStack(alignment: .leading, spacing: 11) {
// Avatar + Name + Zeitstempel
HStack(spacing: 9) {
Circle()
.fill(Color.nightRaised)
.frame(width: 32, height: 32)
RoundedRectangle(cornerRadius: 4)
.fill(Color.nightRaised)
.frame(width: 90, height: 13)
Spacer()
RoundedRectangle(cornerRadius: 4)
.fill(Color.nightRaised)
.frame(width: 36, height: 11)
}
// Content-Zeilen
VStack(alignment: .leading, spacing: 6) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.nightRaised)
.frame(height: 13)
RoundedRectangle(cornerRadius: 4)
.fill(Color.nightRaised)
.frame(width: 180, height: 13)
}
// Resonance-Button Platzhalter
RoundedRectangle(cornerRadius: 8)
.fill(Color.nightRaised)
.frame(width: 90, height: 24)
}
.padding(.leading, 14)
.padding(.trailing, 16)
.padding(.vertical, 16)
}
.shimmer()
}
}
// MARK: - Skeleton Profile Header
/// Platzhalter für den Profilkopf beim Laden.
struct SkeletonProfileHeader: View {
var body: some View {
VStack(spacing: 16) {
// Avatar
Circle()
.fill(Color.nightRaised)
.frame(width: 76, height: 76)
// Name + Username
VStack(spacing: 8) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.nightRaised)
.frame(width: 120, height: 18)
RoundedRectangle(cornerRadius: 4)
.fill(Color.nightRaised)
.frame(width: 80, height: 13)
}
// Stats
HStack(spacing: 36) {
ForEach(0..<3, id: \.self) { _ in
VStack(spacing: 4) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.nightRaised)
.frame(width: 28, height: 16)
RoundedRectangle(cornerRadius: 4)
.fill(Color.nightRaised)
.frame(width: 48, height: 11)
}
}
}
// Action Button
RoundedRectangle(cornerRadius: 10)
.fill(Color.nightRaised)
.frame(height: 40)
.padding(.horizontal, 48)
}
.padding(.top, 28)
.padding(.bottom, 20)
.shimmer()
}
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SUPABASE_URL</key>
<string>$(SUPABASE_URL)</string>
<key>SUPABASE_ANON_KEY</key>
<string>$(SUPABASE_ANON_KEY)</string>
</dict>
</plist>
+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")
}
+42
View File
@@ -0,0 +1,42 @@
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)
#if DEBUG
enum DevCredentials {
static let email = "dev@nightly.test"
static let password = "TestPassword123!"
}
#endif
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
}()
}
@@ -0,0 +1,87 @@
import Combine
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,390 @@
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? {
get async {
try? await self.auth.session.user.id
}
}
}
// MARK: - Profil
extension SupabaseClient {
func getMyProfile() async throws -> Profile {
guard let uid = await 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 = await 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 = await currentUserId else { return }
try await self.from("profiles")
.update(["push_token": token])
.eq("id", value: uid)
.execute()
}
func removePushToken() async throws {
guard let uid = await 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 = await 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 = await 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 = await currentUserId else { throw AuthError.notAuthenticated }
struct Params: Encodable {
let userId: String
let content: String
let mood: String
let isAnonymous: Bool
}
try await self.from("posts").insert(
Params(userId: uid.uuidString, content: content, mood: mood.rawValue, isAnonymous: 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 = await 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 = await 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 = await 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 = await currentUserId else { throw AuthError.notAuthenticated }
struct Params: Encodable {
let postId: String
let reporterId: String
let reason: String
let details: String?
}
try await self.from("reports").insert(
Params(postId: postId, reporterId: uid.uuidString, reason: reason, details: details)
).execute()
}
}
// MARK: - Whispers
extension SupabaseClient {
func sendWhisper(toUserId: UUID, content: String, postId: String?) async throws {
guard let uid = await currentUserId else { throw AuthError.notAuthenticated }
struct Params: Encodable {
let fromUserId: String
let toUserId: String
let content: String
let postId: String?
}
try await self.from("whispers").insert(
Params(fromUserId: uid.uuidString, toUserId: toUserId.uuidString, content: content, postId: postId)
).execute()
}
func getMyWhispers() async throws -> [Whisper] {
guard let uid = await 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,41 @@
import Combine
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,34 @@
import Combine
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
}
}
}
@@ -0,0 +1,325 @@
import Combine
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 {
Haptics.select()
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 supabase.createPost(
content: text.trimmingCharacters(in: .whitespacesAndNewlines),
mood: mood,
isAnonymous: isAnonymous
)
Haptics.success()
appState.markAsPosted()
dismiss()
} catch {
Haptics.error()
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) {
Haptics.soft()
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"
}
}
+303
View File
@@ -0,0 +1,303 @@
import Combine
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
@State private var postsAppeared = false
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(Array(viewModel.groupedPosts.enumerated()), id: \.element.nightLabel) { groupIndex, group in
// Nacht-Header
DiaryNightHeader(label: group.nightLabel, count: group.posts.count)
.opacity(postsAppeared ? 1 : 0)
.animation(
.spring(duration: 0.35, bounce: 0.1)
.delay(Double(min(groupIndex, 6)) * 0.06),
value: postsAppeared
)
ForEach(group.posts) { post in
DiaryPostRow(post: post) {
Task { await viewModel.deletePost(post) }
}
.opacity(postsAppeared ? 1 : 0)
.offset(y: postsAppeared ? 0 : 12)
.animation(
.spring(duration: 0.35, bounce: 0.1)
.delay(Double(min(groupIndex, 6)) * 0.06 + 0.03),
value: postsAppeared
)
Divider().background(Color.nightBorder).padding(.leading, 16)
}
}
.onAppear { postsAppeared = true }
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()
}
}
+395
View File
@@ -0,0 +1,395 @@
import SwiftUI
// MARK: - Feed
struct FeedView: View {
@StateObject private var viewModel = FeedViewModel()
@EnvironmentObject var appState: AppState
var realtime: RealtimeService? = nil
@State private var postsAppeared = false
// 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(Array(nightPosts.enumerated()), id: \.element.id) { index, post in
PostRowView(post: post) {
Task { await viewModel.resonate(post) }
}
.opacity(postsAppeared ? 1 : 0)
.offset(y: postsAppeared ? 0 : 14)
.animation(
.spring(duration: 0.35, bounce: 0.1)
.delay(Double(min(index, 10)) * 0.04),
value: postsAppeared
)
Divider()
.background(Color.nightBorder)
.padding(.leading, 16)
}
.onAppear { postsAppeared = true }
if viewModel.isLoading {
ForEach(0..<5, id: \.self) { _ in
SkeletonPostRow()
Divider().background(Color.nightBorder).padding(.leading, 16)
}
}
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 text: String
init(title: String, _ text: () -> String) {
self.title = title
self.text = text()
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.nightLabel(14, weight: .semibold))
.foregroundColor(.nightPrimary)
Text(text)
.font(.nightBody(14))
.foregroundColor(.nightSecondary)
.lineSpacing(4)
.fixedSize(horizontal: false, vertical: true)
}
}
}
@@ -0,0 +1,208 @@
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)
.scaleEffect(selectedTab == 0 ? 1 : 0.97)
.allowsHitTesting(selectedTab == 0)
DiaryView()
.environmentObject(appState)
.opacity(selectedTab == 1 ? 1 : 0)
.scaleEffect(selectedTab == 1 ? 1 : 0.97)
.allowsHitTesting(selectedTab == 1)
ProfileView(
user: appState.currentUser ?? .preview,
isCurrentUser: true
)
.environmentObject(appState)
.opacity(selectedTab == 2 ? 1 : 0)
.scaleEffect(selectedTab == 2 ? 1 : 0.97)
.allowsHitTesting(selectedTab == 2)
}
.animation(.spring(duration: 0.25, bounce: 0.1), value: selectedTab)
}
}
// 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) {
Haptics.select()
selectedTab = 0
}
Spacer()
// Diary
TabIcon(icon: "book.closed", activeIcon: "book.closed.fill", isSelected: selectedTab == 1) {
Haptics.select()
selectedTab = 1
}
Spacer()
// Center: Compose
ComposeTabButton(windowState: windowState, onTap: onCompose)
Spacer()
// Profile
TabIcon(icon: "person", activeIcon: "person.fill", isSelected: selectedTab == 2) {
Haptics.select()
selectedTab = 2
}
Spacer()
// Settings
TabIcon(icon: "gearshape", activeIcon: "gearshape.fill", isSelected: false) {
Haptics.select()
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 }
Haptics.medium()
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 }
}
}
}
@@ -0,0 +1,292 @@
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 {
Haptics.light()
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
}
}
}
@@ -0,0 +1,356 @@
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
@State private var postsAppeared = 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) {
if viewModel.isLoading {
SkeletonProfileHeader()
} else {
ProfileHeader(
user: user,
streak: viewModel.streak,
isCurrentUser: isCurrentUser
)
}
Divider().background(Color.nightBorder)
// Posts
if viewModel.isLoading {
ForEach(0..<4, id: \.self) { _ in
SkeletonPostRow()
Divider().background(Color.nightBorder).padding(.leading, 16)
}
} else if viewModel.posts.isEmpty {
EmptyProfilePosts()
} else {
LazyVStack(spacing: 0) {
ForEach(Array(viewModel.posts.enumerated()), id: \.element.id) { index, post in
PostRowView(post: post) {}
.opacity(postsAppeared ? 1 : 0)
.offset(y: postsAppeared ? 0 : 16)
.animation(
.spring(duration: 0.4, bounce: 0.12)
.delay(Double(min(index, 8)) * 0.05),
value: postsAppeared
)
Divider().background(Color.nightBorder).padding(.leading, 16)
}
}
.onAppear { postsAppeared = true }
}
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
// Staggered entrance
@State private var avatarAppeared = false
@State private var infoAppeared = false
@State private var statsAppeared = false
@State private var actionAppeared = 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) {
// Avatar mit Glow Ring
ZStack {
Circle()
.fill(
RadialGradient(
colors: [Color.nightPurple.opacity(0.12), .clear],
center: .center,
startRadius: 34,
endRadius: 52
)
)
.frame(width: 96, height: 96)
Circle()
.strokeBorder(
LinearGradient(
colors: [Color.nightPurple.opacity(0.35), Color.nightPurpleSoft.opacity(0.1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 1.5
)
.frame(width: 82, height: 82)
AvatarView(user: user, size: 76)
}
.scaleEffect(avatarAppeared ? 1 : 0.8)
.opacity(avatarAppeared ? 1 : 0)
// Name, Username, Bio
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)
}
}
.opacity(infoAppeared ? 1 : 0)
.offset(y: infoAppeared ? 0 : 8)
// Stats
HStack(spacing: 36) {
ProfileStat(value: user.postCount, label: "nächte")
ProfileStat(value: user.followerCount, label: "follower")
ProfileStat(value: user.followingCount, label: "following")
}
.opacity(statsAppeared ? 1 : 0)
.offset(y: statsAppeared ? 0 : 10)
// Streak
if streak > 0 {
StreakBadge(streak: streak)
.opacity(actionAppeared ? 1 : 0)
.offset(y: actionAppeared ? 0 : 8)
}
// 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)
.opacity(actionAppeared ? 1 : 0)
.offset(y: actionAppeared ? 0 : 8)
} else {
FollowButton(
isFollowing: $isFollowing,
isLoading: $isFollowLoading,
onToggle: { Task { await toggleFollow() } }
)
.opacity(actionAppeared ? 1 : 0)
.offset(y: actionAppeared ? 0 : 8)
}
}
.padding(.horizontal, 20)
.padding(.top, 28)
.padding(.bottom, 20)
}
.onAppear {
withAnimation(.spring(duration: 0.6, bounce: 0.3).delay(0.05)) { avatarAppeared = true }
withAnimation(.spring(duration: 0.6, bounce: 0.2).delay(0.15)) { infoAppeared = true }
withAnimation(.spring(duration: 0.5, bounce: 0.2).delay(0.25)) { statsAppeared = true }
withAnimation(.spring(duration: 0.5, bounce: 0.2).delay(0.35)) { actionAppeared = true }
}
}
func toggleFollow() async {
Haptics.light()
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)
}
withAnimation(.spring(duration: 0.3, bounce: 0.4)) {
isFollowing.toggle()
}
Haptics.success()
} catch {
Haptics.error()
}
}
}
// MARK: - Follow Button
struct FollowButton: View {
@Binding var isFollowing: Bool
@Binding var isLoading: Bool
let onToggle: () -> Void
var body: some View {
Button(action: onToggle) {
Group {
if isLoading {
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))
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(isFollowing ? Color.nightBorder : .clear, lineWidth: 1)
)
}
.disabled(isLoading)
.padding(.horizontal, 48)
.animation(.spring(duration: 0.3, bounce: 0.3), value: isFollowing)
}
}
// MARK: - Streak Badge
struct StreakBadge: View {
let streak: Int
@State private var fireScale: CGFloat = 1.0
var isHot: Bool { streak >= 7 }
var body: some View {
HStack(spacing: 6) {
Image(systemName: isHot ? "flame.fill" : "flame")
.foregroundColor(isHot ? .orange : .nightSecondary)
.scaleEffect(fireScale)
Text("\(streak) Nächte in Folge")
.font(.nightLabel(13, weight: isHot ? .semibold : .regular))
.foregroundColor(isHot ? .nightPrimary : .nightSecondary)
}
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(
ZStack {
Capsule().fill(Color.nightRaised)
if isHot {
Capsule().fill(Color.orange.opacity(0.06))
Capsule().strokeBorder(Color.orange.opacity(0.2), lineWidth: 1)
}
}
)
.onAppear {
guard isHot else { return }
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
fireScale = 1.18
}
}
}
}
// MARK: - Profile Stat
struct ProfileStat: View {
let value: Int
let label: String
@State private var displayValue: Int = 0
@State private var appeared = false
var body: some View {
VStack(spacing: 3) {
Text("\(displayValue)")
.font(.nightTitle(18))
.foregroundColor(.nightPrimary)
.contentTransition(.numericText(value: Double(displayValue)))
Text(label)
.font(.nightLabel(12))
.foregroundColor(.nightSecondary)
}
.onAppear {
guard !appeared else { return }
appeared = true
withAnimation(.easeOut(duration: 0.5).delay(0.3)) {
displayValue = value
}
}
}
}
// MARK: - Empty State
struct EmptyProfilePosts: View {
@State private var appeared = false
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)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 12)
.onAppear {
withAnimation(.spring(duration: 0.5, bounce: 0.2).delay(0.2)) {
appeared = true
}
}
}
}
@@ -0,0 +1,18 @@
import SwiftUI
struct RootView: View {
@EnvironmentObject var appState: AppState
var body: some View {
Group {
if appState.isAuthenticated {
MainTabView()
.transition(.opacity.combined(with: .scale(scale: 0.98)))
} else {
OnboardingView()
.transition(.opacity.combined(with: .scale(scale: 1.02)))
}
}
.animation(.spring(duration: 0.5, bounce: 0.15), value: appState.isAuthenticated)
}
}
@@ -0,0 +1,346 @@
import SwiftUI
import UserNotifications
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
@State private var appeared = false
var body: some View {
NavigationStack {
ZStack {
Color.nightBase.ignoresSafeArea()
ScrollView {
VStack(spacing: 20) {
// Account
SettingsSection {
if let user = appState.currentUser {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.nightPurple.opacity(0.1))
.frame(width: 52, height: 52)
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)
}
Spacer()
}
.padding(.vertical, 4)
}
}
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 12)
.animation(.spring(duration: 0.4, bounce: 0.15).delay(0.05), value: appeared)
// Benachrichtigungen
SettingsSection(header: "benachrichtigungen") {
SettingsRow {
HStack {
SettingsIcon(icon: "bell.fill", color: .nightPurple)
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)
.labelsHidden()
}
}
Divider().background(Color.nightBorder).padding(.leading, 52)
SettingsRow {
HStack(spacing: 8) {
Image(systemName: "info.circle")
.foregroundColor(.nightTertiary)
.font(.system(size: 13))
Text("Push-Benachrichtigungen benötigen einen bezahlten Apple Developer Account ($99/Jahr).")
.font(.nightLabel(12))
.foregroundColor(.nightTertiary)
.lineSpacing(3)
}
.padding(.vertical, 2)
}
}
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 12)
.animation(.spring(duration: 0.4, bounce: 0.15).delay(0.1), value: appeared)
// Rechtliches
SettingsSection(header: "rechtliches") {
SettingsRow {
Button {
showLegal = true
} label: {
HStack {
SettingsIcon(icon: "doc.text", color: .nightSecondary)
Text("Impressum & Datenschutz")
.font(.nightLabel(15))
.foregroundColor(.nightPrimary)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.nightTertiary)
}
}
}
Divider().background(Color.nightBorder).padding(.leading, 52)
SettingsRow {
HStack {
SettingsIcon(icon: "info.circle", color: .nightSecondary)
Text("Version")
.font(.nightLabel(15))
.foregroundColor(.nightPrimary)
Spacer()
Text(appVersion)
.font(.nightMono(13))
.foregroundColor(.nightTertiary)
}
}
}
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 12)
.animation(.spring(duration: 0.4, bounce: 0.15).delay(0.15), value: appeared)
// Account-Aktionen
SettingsSection(header: "account") {
SettingsRow {
Button {
Haptics.medium()
appState.signOut()
dismiss()
} label: {
HStack {
SettingsIcon(icon: "rectangle.portrait.and.arrow.right", color: .nightSecondary)
Text("abmelden")
.font(.nightLabel(15))
.foregroundColor(.nightPrimary)
Spacer()
}
}
}
Divider().background(Color.nightBorder).padding(.leading, 52)
SettingsRow {
Button {
Haptics.warning()
showDeleteConfirm = true
} label: {
HStack {
SettingsIcon(icon: "trash", color: .nightRed)
Text("account löschen")
.font(.nightLabel(15))
.foregroundColor(.nightRed)
Spacer()
}
}
}
}
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 12)
.animation(.spring(duration: 0.4, bounce: 0.15).delay(0.2), value: appeared)
Color.clear.frame(height: 40)
}
.padding(.horizontal, 16)
.padding(.top, 8)
}
}
.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)
.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.")
}
.sheet(isPresented: $showDeleteFinal) {
DeleteAccountSheet(
password: $deletePassword,
isDeleting: isDeleting,
error: deleteError,
onDelete: { Task { await deleteAccount() } }
)
}
.onAppear {
checkNotificationStatus()
appeared = true
}
}
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
}
}
}
// MARK: - Settings Components
struct SettingsSection<Content: View>: View {
var header: String? = nil
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if let header {
Text(header)
.font(.nightLabel(12, weight: .semibold))
.foregroundColor(.nightSecondary)
.kerning(0.5)
.padding(.leading, 4)
.padding(.bottom, 8)
}
VStack(spacing: 0) {
content
}
.background(Color.nightSurface)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.strokeBorder(Color.nightBorder, lineWidth: 1)
)
}
}
}
struct SettingsRow<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
content
.padding(.horizontal, 16)
.padding(.vertical, 13)
}
}
struct SettingsIcon: View {
let icon: String
let color: Color
var body: some View {
Image(systemName: icon)
.font(.system(size: 13, weight: .medium))
.foregroundColor(color)
.frame(width: 28, height: 28)
.background(color.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 7))
}
}
// MARK: - Delete Account Sheet
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 {
Haptics.warning()
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])
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
</dict>
</plist>
@@ -0,0 +1,19 @@
//
// thoughtsTests.swift
// thoughtsTests
//
// Created by Dennis Konkol on 22.04.26.
//
import Testing
@testable import thoughts
struct thoughtsTests {
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
// Swift Testing Documentation
// https://developer.apple.com/documentation/testing
}
}
@@ -0,0 +1,43 @@
//
// thoughtsUITests.swift
// thoughtsUITests
//
// Created by Dennis Konkol on 22.04.26.
//
import XCTest
final class thoughtsUITests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// In UI tests its important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
@MainActor
func testExample() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()
// Use XCTAssert and related functions to verify your tests produce the correct results.
// XCUIAutomation Documentation
// https://developer.apple.com/documentation/xcuiautomation
}
@MainActor
func testLaunchPerformance() throws {
// This measures how long it takes to launch your application.
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
}
@@ -0,0 +1,35 @@
//
// thoughtsUITestsLaunchTests.swift
// thoughtsUITests
//
// Created by Dennis Konkol on 22.04.26.
//
import XCTest
final class thoughtsUITestsLaunchTests: XCTestCase {
override class var runsForEachTargetApplicationUIConfiguration: Bool {
true
}
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func testLaunch() throws {
let app = XCUIApplication()
app.launch()
// Insert steps here to perform after app launch but before taking a screenshot,
// such as logging into a test account or navigating somewhere in the app
// XCUIAutomation Documentation
// https://developer.apple.com/documentation/xcuiautomation
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Launch Screen"
attachment.lifetime = .keepAlways
add(attachment)
}
}