Initial commit: nightly iOS app + Supabase backend
iOS SwiftUI app with Supabase auth/realtime, Node.js backend, Docker/Supabase self-hosted infrastructure, and APNs scheduler. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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()
|
||||
)
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 2–5 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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Impressum + Datenschutzerklärung
|
||||
/// ⚠️ Muss vor dem Launch von einem Anwalt geprüft/ergänzt werden!
|
||||
/// Kosten: ca. 300–500€ 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. 300–500€.
|
||||
"""
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user