diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 0000000..36d25cb --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "xcode-tools": { + "command": "xcrun", + "args": [ + "mcpbridge" + ] + } + } +} \ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..5ac7146 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "xcode": { + "command": "npx", + "args": ["-y", "xcode-mcp-server"] + } + } +} \ No newline at end of file diff --git a/infrastructure/migrations/002_fixes_and_additions.sql b/infrastructure/migrations/002_fixes_and_additions.sql new file mode 100644 index 0000000..419c038 --- /dev/null +++ b/infrastructure/migrations/002_fixes_and_additions.sql @@ -0,0 +1,132 @@ +-- nightly — Fix migration: align DB with iOS app + add missing features +-- Run after 001_schema.sql + +-- ── 1. Fix mood enum: replace old values with iOS app values ───────────────── + +-- Drop dependent objects first +DROP VIEW IF EXISTS public.feed_posts; + +ALTER TABLE public.posts ALTER COLUMN mood TYPE text USING mood::text; +DROP TYPE IF EXISTS public.post_mood; + +CREATE TYPE public.post_mood AS ENUM ('still', 'unruhig', 'schwer', 'geladen', 'zart', 'dunkel'); +ALTER TABLE public.posts ALTER COLUMN mood TYPE public.post_mood USING mood::public.post_mood; + +-- ── 2. Increase content length from 280 to 420 ─────────────────────────────── + +ALTER TABLE public.posts DROP CONSTRAINT posts_content_check; +ALTER TABLE public.posts ADD CONSTRAINT posts_content_check + CHECK (LENGTH(TRIM(content)) BETWEEN 1 AND 420); + +-- ── 3. Add content warning columns to posts ───────────────────────────────── + +ALTER TABLE public.posts ADD COLUMN IF NOT EXISTS has_content_warning BOOLEAN DEFAULT FALSE NOT NULL; +ALTER TABLE public.posts ADD COLUMN IF NOT EXISTS content_warning_type TEXT; + +-- ── 4. Recreate feed_posts view with content_warning fields ────────────────── + +CREATE OR REPLACE VIEW public.feed_posts AS +SELECT + p.id, + p.content, + p.mood, + p.is_anonymous, + p.created_at, + p.has_content_warning, + p.content_warning_type, + CASE WHEN p.is_anonymous THEN NULL ELSE p.user_id END AS author_id, + CASE WHEN p.is_anonymous THEN NULL ELSE pr.username END AS author_username, + CASE WHEN p.is_anonymous THEN NULL ELSE pr.display_name END AS author_display_name, + CASE WHEN p.is_anonymous THEN NULL ELSE pr.avatar_url END AS author_avatar_url, + COALESCE(( + SELECT COUNT(*) FROM public.resonances r WHERE r.post_id = p.id + ), 0)::int AS resonance_count +FROM public.posts p +JOIN public.profiles pr ON pr.id = p.user_id +WHERE p.deleted_at IS NULL + AND p.review_status IN ('ok', 'approved') + AND p.created_at > NOW() - INTERVAL '14 hours'; + +-- ── 5. Add get_streak RPC ──────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION public.get_streak(p_user_id UUID) +RETURNS TABLE(streak INT) LANGUAGE plpgsql STABLE AS $$ +DECLARE + v_streak INT := 0; + v_check_date DATE := CURRENT_DATE; + v_has_post BOOLEAN; +BEGIN + LOOP + SELECT EXISTS( + SELECT 1 FROM public.posts + WHERE user_id = p_user_id + AND deleted_at IS NULL + AND (created_at AT TIME ZONE 'Europe/Berlin')::DATE = v_check_date + ) INTO v_has_post; + + IF v_has_post THEN + v_streak := v_streak + 1; + v_check_date := v_check_date - 1; + ELSE + EXIT; + END IF; + END LOOP; + + -- Check if user posted today; if not, streak is 0 + IF v_streak = 0 THEN + RETURN NEXT; + ELSE + RETURN QUERY SELECT v_streak; + END IF; +END; +$$; + +-- ── 6. Add get_resonance_count RPC ─────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION public.get_resonance_count(p_user_id UUID) +RETURNS TABLE(count INT) LANGUAGE plpgsql STABLE AS $$ +BEGIN + RETURN QUERY + SELECT COUNT(*)::int AS count + FROM public.resonances r + JOIN public.posts p ON p.id = r.post_id + WHERE p.user_id = p_user_id + AND p.deleted_at IS NULL; +END; +$$; + +-- ── 7. Add public_profile view for other-user profile lookups ──────────────── + +CREATE OR REPLACE VIEW public.public_profile AS +SELECT + p.id, + p.username, + p.display_name, + p.bio, + p.avatar_url, + p.is_pro, + p.is_admin, + p.created_at, + (SELECT COUNT(*) FROM public.posts WHERE user_id = p.id AND deleted_at IS NULL)::int AS post_count, + (SELECT COUNT(*) FROM public.resonances r JOIN public.posts p2 ON p2.id = r.post_id WHERE p2.user_id = p.id AND p2.deleted_at IS NULL)::int AS resonance_count, + (SELECT COUNT(*) FROM public.follows WHERE following_id = p.id)::int AS follower_count, + (SELECT COUNT(*) FROM public.follows WHERE follower_id = p.id)::int AS following_count +FROM public.profiles p; + +-- ── 8. Add profile view RLS (public_profile is a view, inherits from profiles) ─ + +-- Allow updates to profiles row +-- (already handled by existing policy, no change needed) + +-- ── 9. Enable Realtime for posts table ──────────────────────────────────────── + +ALTER PUBLICATION supabase_realtime ADD TABLE public.posts; + +-- ── 10. Fix RLS for posts: allow reading all non-deleted approved posts ─────── + +DROP POLICY IF EXISTS posts_select ON public.posts; +CREATE POLICY "posts_select" ON public.posts FOR SELECT + USING ( + deleted_at IS NULL + AND review_status IN ('ok', 'approved') + ); \ No newline at end of file diff --git a/infrastructure/migrations/003_seed_data.sql b/infrastructure/migrations/003_seed_data.sql new file mode 100644 index 0000000..83f508c --- /dev/null +++ b/infrastructure/migrations/003_seed_data.sql @@ -0,0 +1,112 @@ +-- nightly — Seed data: realistic German users and posts for testing +-- Run manually or via supabase db reset --seed + +-- ── Cleanup (idempotent) ──────────────────────────────────────────────────── + +DELETE FROM resonances; +DELETE FROM whispers; +DELETE FROM follows; +DELETE FROM reports; +DELETE FROM posts; +DELETE FROM profiles WHERE id != auth.uid(); -- keep own account +-- Note: we can't delete from auth.users easily, so we'll create new ones + +-- ── Create test users via Supabase Auth ────────────────────────────────────── +-- These must be created via auth.users first (or signup), then profiles get auto-created. +-- For seeding we insert directly into profiles with UUIDs. + +INSERT INTO profiles (id, username, display_name, bio, is_pro) VALUES + ('a0000000-0000-0000-0000-000000000001', 'nachtvogel_', 'Nachtvogel', '3 Uhr ist meine goldene Stunde', true), + ('a0000000-0000-0000-0000-000000000002', 'insomniac_', 'can''t sleep', NULL, false), + ('a0000000-0000-0000-0000-000000000003', 'felix.nacht', 'Felix', NULL, false), + ('a0000000-0000-0000-0000-000000000004', '4am_thoughts', 'Lena', 'Nachtgedanken auf Deutsch', false), + ('a0000000-0000-0000-0000-000000000005', 'mondkind', 'Mondkind', NULL, true), + ('a0000000-0000-0000-0000-000000000006', 'keinschlaf_', 'k.einschlaf', 'Aufgewacht und nicht mehr müde', false), + ('a0000000-0000-0000-0000-000000000007', 'sterne_sehn', 'Sternesehende', 'Liege auf dem Dach und schaue hoch', false), + ('a0000000-0000-0000-0000-000000000008', 'dunkelheit_', 'Dunkelheit', NULL, false), + ('a0000000-0000-0000-0000-000000000009', 'leise.stimme', 'Leise Stimme', 'Ich schreibe nachts am besten', false), + ('a0000000-0000-0000-0000-000000000010', 'wolkenzug_', 'Wolkenzug', 'Gedanken die wie Wolken vorbeiziehen', false) +ON CONFLICT (id) DO NOTHING; + +-- ── Posts ───────────────────────────────────────────────────────────────────── + +INSERT INTO posts (id, user_id, content, mood, is_anonymous, has_content_warning, content_warning_type, created_at) VALUES + -- User 1: nachtvogel_ + ('b0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'manchmal ist die stille nachts lauter als alle geräusche tagsüber', 'still', false, false, NULL, NOW() - INTERVAL '5 minutes'), + ('b0000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'regentropfen am fenster sind das einzige was mich gerade noch companie macht', 'still', false, false, NULL, NOW() - INTERVAL '30 minutes'), + + -- User 2: insomniac_ + ('b0000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000002', 'das licht vom handy macht alles schlimmer aber ich leg es trotzdem nicht weg', 'unruhig', false, false, NULL, NOW() - INTERVAL '15 minutes'), + ('b0000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000002', '3 uhr und ich rechne vor allem leb ich noch wenn ich aufwache', 'unruhig', false, false, NULL, NOW() - INTERVAL '2 hours'), + + -- User 3: felix.nacht + ('b0000000-0000-0000-0000-000000000005', 'a0000000-0000-0000-0000-000000000003', 'hab gerade realisiert dass ich seit 4 stunden auf tiktok bin und morgen um 7 aufstehen muss', 'geladen', false, false, NULL, NOW() - INTERVAL '1 minute'), + ('b0000000-0000-0000-0000-000000000006', 'a0000000-0000-0000-0000-000000000003', 'hab mein zimmer aufgeräumt statt zu schlafen. zumindest irgendetwas produktives heute nacht', 'geladen', false, true, 'other', NOW() - INTERVAL '45 minutes'), + + -- User 4: 4am_thoughts + ('b0000000-0000-0000-0000-000000000007', 'a0000000-0000-0000-0000-000000000004', 'traum von einer stadt in der niemand schläft und alle auf den dächern sitzen und in den himmel schauen', 'zart', false, false, NULL, NOW() - INTERVAL '2 hours'), + ('b0000000-0000-0000-0000-000000000008', 'a0000000-0000-0000-0000-000000000004', 'die nacht hat eine andere sprache als der tag', 'still', false, false, NULL, NOW() - INTERVAL '4 hours'), + + -- User 5: mondkind + ('b0000000-0000-0000-0000-000000000009', 'a0000000-0000-0000-0000-000000000005', 'wenn der mond hell genug ist brauch ich kein licht auf dem handy', 'zart', false, false, NULL, NOW() - INTERVAL '20 minutes'), + + -- Anonymous posts + ('b0000000-0000-0000-0000-000000000010', 'a0000000-0000-0000-0000-000000000001', 'ich warte irgendwie immer noch auf eine nachricht von dir obwohl ich weiß dass sie nicht kommt', 'schwer', true, false, NULL, NOW() - INTERVAL '5 minutes'), + ('b0000000-0000-0000-0000-000000000011', 'a0000000-0000-0000-0000-000000000002', 'ich hab meiner besten freundin gesagt dass ich sie vermisse und sie hat nur geantwortet mit einem smiley', 'zart', true, false, NULL, NOW() - INTERVAL '90 minutes'), + ('b0000000-0000-0000-0000-000000000012', 'a0000000-0000-0000-0000-000000000003', 'ich kann nicht aufhören an ihn zu denken und das schlimmste ist ich weiß dass es falsch ist', 'dunkel', true, true, 'other', NOW() - INTERVAL '3 hours'), + ('b0000000-0000-0000-0000-000000000013', 'a0000000-0000-0000-0000-000000000004', 'warum denk ich um 3 uhr morgens noch an das was ich 2019 gesagt hab', 'schwer', true, false, NULL, NOW() - INTERVAL '3 minutes'), + ('b0000000-0000-0000-0000-000000000014', 'a0000000-0000-0000-0000-000000000005', 'manchmal wünsch ich mir jemand würde einfach nur nachts bei mir sein ohne dass wir reden müssen', 'zart', true, false, NULL, NOW() - INTERVAL '1 hour'), + + -- More diverse posts + ('b0000000-0000-0000-0000-000000000015', 'a0000000-0000-0000-0000-000000000006', 'kopfhörer auf. playlist die nur nachts funktioniert. welt draußen geht weiter aber hier drinnen ist sie gestoppt', 'still', false, false, NULL, NOW() - INTERVAL '10 minutes'), + ('b0000000-0000-0000-0000-000000000016', 'a0000000-0000-0000-0000-000000000007', 'lieber allein wach als in gesellschaft die mich nicht versteht', 'dunkel', false, false, NULL, NOW() - INTERVAL '40 minutes'), + ('b0000000-0000-0000-0000-000000000017', 'a0000000-0000-0000-0000-000000000008', 'ich hab so viel energie um 3 uhr aber null motivation um 15 uhr', 'geladen', true, false, NULL, NOW() - INTERVAL '55 minutes'), + ('b0000000-0000-0000-0000-000000000018', 'a0000000-0000-0000-0000-000000000009', 'die nacht ist der einzige ort wo ich ehrlich sein kann', 'dunkel', false, false, NULL, NOW() - INTERVAL '1 hour 20 minutes'), + ('b0000000-0000-0000-0000-000000000019', 'a0000000-0000-0000-0000-000000000010', 'wolken ziehen vorbei und ich frage mich ob sie auch nachts nicht schlafen können', 'still', false, false, NULL, NOW() - INTERVAL '2 hours 30 minutes'), + ('b0000000-0000-0000-0000-000000000020', 'a0000000-0000-0000-0000-000000000001', 'jeder irgendwo auf der welt ist gerade auch wach und das tröstet mich mehr als es sollte', 'zart', false, false, NULL, NOW() - INTERVAL '8 minutes'), + + -- Content warning posts + ('b0000000-0000-0000-0000-000000000021', 'a0000000-0000-0000-0000-000000000004', 'heute nacht war das erste mal seit langem dass ich dachte vielleicht wird es doch noch besser', 'zart', false, true, 'other', NOW() - INTERVAL '25 minutes'), + ('b0000000-0000-0000-0000-000000000022', 'a0000000-0000-0000-0000-000000000006', 'das problem ist nicht dass ich nicht schlafen kann. das problem ist dass ich nicht aufhören kann zu denken', 'unruhig', false, false, NULL, NOW() - INTERVAL '50 minutes') +ON CONFLICT DO NOTHING; + +-- ── Resonances ──────────────────────────────────────────────────────────────── + +INSERT INTO resonances (post_id, user_id, created_at) VALUES + ('b0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000002', NOW() - INTERVAL '4 minutes'), + ('b0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000003', NOW() - INTERVAL '3 minutes'), + ('b0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000004', NOW() - INTERVAL '2 minutes'), + ('b0000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '10 minutes'), + ('b0000000-0000-0000-0000-000000000005', 'a0000000-0000-0000-0000-000000000005', NOW() - INTERVAL '30 seconds'), + ('b0000000-0000-0000-0000-000000000010', 'a0000000-0000-0000-0000-000000000007', NOW() - INTERVAL '4 minutes'), + ('b0000000-0000-0000-0000-000000000010', 'a0000000-0000-0000-0000-000000000008', NOW() - INTERVAL '3 minutes'), + ('b0000000-0000-0000-0000-000000000011', 'a0000000-0000-0000-0000-000000000009', NOW() - INTERVAL '45 minutes'), + ('b0000000-0000-0000-0000-000000000011', 'a0000000-0000-0000-0000-000000000010', NOW() - INTERVAL '40 minutes'), + ('b0000000-0000-0000-0000-000000000012', 'a0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '2 hours'), + ('b0000000-0000-0000-0000-000000000013', 'a0000000-0000-0000-0000-000000000005', NOW() - INTERVAL '2 minutes'), + ('b0000000-0000-0000-0000-000000000013', 'a0000000-0000-0000-0000-000000000006', NOW() - INTERVAL '1 minute'), + ('b0000000-0000-0000-0000-000000000009', 'a0000000-0000-0000-0000-000000000004', NOW() - INTERVAL '15 minutes'), + ('b0000000-0000-0000-0000-000000000020', 'a0000000-0000-0000-0000-000000000002', NOW() - INTERVAL '5 minutes'), + ('b0000000-0000-0000-0000-000000000020', 'a0000000-0000-0000-0000-000000000009', NOW() - INTERVAL '3 minutes'), + ('b0000000-0000-0000-0000-000000000007', 'a0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '1 hour') +ON CONFLICT DO NOTHING; + +-- ── Follows ────────────────────────────────────────────────────────────────── + +INSERT INTO follows (follower_id, following_id, created_at) VALUES + ('a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000002', NOW() - INTERVAL '3 days'), + ('a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000005', NOW() - INTERVAL '1 day'), + ('a0000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', NOW() - INTERVAL '5 days'), + ('a0000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '2 days'), + ('a0000000-0000-0000-0000-000000000005', 'a0000000-0000-0000-0000-000000000006', NOW() - INTERVAL '12 hours'), + ('a0000000-0000-0000-0000-000000000006', 'a0000000-0000-0000-0000-000000000007', NOW() - INTERVAL '6 hours'), + ('a0000000-0000-0000-0000-000000000009', 'a0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '4 days'), + ('a0000000-0000-0000-0000-000000000010', 'a0000000-0000-0000-0000-000000000005', NOW() - INTERVAL '1 day') +ON CONFLICT DO NOTHING; + +-- ── Whispers ────────────────────────────────────────────────────────────────── + +INSERT INTO whispers (id, from_user_id, to_user_id, content, read_at, created_at) VALUES + ('c0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'dein post hat mir gerade alles gesagt was ich fühle', NULL, NOW() - INTERVAL '20 minutes'), + ('c0000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000005', 'a0000000-0000-0000-0000-000000000001', 'wollte dir nur sagen dass du nicht allein bist diese nacht', NOW() - INTERVAL '10 minutes', NOW() - INTERVAL '2 hours') +ON CONFLICT DO NOTHING; \ No newline at end of file diff --git a/ios/.gemini/settings.json b/ios/.gemini/settings.json new file mode 100644 index 0000000..36d25cb --- /dev/null +++ b/ios/.gemini/settings.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "xcode-tools": { + "command": "xcrun", + "args": [ + "mcpbridge" + ] + } + } +} \ No newline at end of file diff --git a/ios/NightThoughts/AppState.swift b/ios/NightThoughts/AppState.swift deleted file mode 100644 index e046c4c..0000000 --- a/ios/NightThoughts/AppState.swift +++ /dev/null @@ -1,124 +0,0 @@ -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() } - } - } -} diff --git a/ios/NightThoughts/Extensions/Colors.swift b/ios/NightThoughts/Extensions/Colors.swift deleted file mode 100644 index c16e459..0000000 --- a/ios/NightThoughts/Extensions/Colors.swift +++ /dev/null @@ -1,79 +0,0 @@ -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) - } -} diff --git a/ios/NightThoughts/Models/Post.swift b/ios/NightThoughts/Models/Post.swift deleted file mode 100644 index 8c6012f..0000000 --- a/ios/NightThoughts/Models/Post.swift +++ /dev/null @@ -1,107 +0,0 @@ -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() - ) - ] -} diff --git a/ios/NightThoughts/Models/User.swift b/ios/NightThoughts/Models/User.swift deleted file mode 100644 index 7ada9b0..0000000 --- a/ios/NightThoughts/Models/User.swift +++ /dev/null @@ -1,25 +0,0 @@ -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 - ) -} diff --git a/ios/NightThoughts/NightlyApp.swift b/ios/NightThoughts/NightlyApp.swift deleted file mode 100644 index 10b21a4..0000000 --- a/ios/NightThoughts/NightlyApp.swift +++ /dev/null @@ -1,67 +0,0 @@ -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") -} diff --git a/ios/NightThoughts/Secrets.swift b/ios/NightThoughts/Secrets.swift deleted file mode 100644 index a4b667b..0000000 --- a/ios/NightThoughts/Secrets.swift +++ /dev/null @@ -1,35 +0,0 @@ -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 - }() -} diff --git a/ios/NightThoughts/Services/APIService.swift b/ios/NightThoughts/Services/APIService.swift deleted file mode 100644 index 52dad3b..0000000 --- a/ios/NightThoughts/Services/APIService.swift +++ /dev/null @@ -1,127 +0,0 @@ -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(_ path: String) async throws -> T { - try await perform(makeRequest("GET", path: path)) - } - - @discardableResult - private func post(_ 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(_ 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(_ 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 - } - } -} diff --git a/ios/NightThoughts/Services/KeychainService.swift b/ios/NightThoughts/Services/KeychainService.swift deleted file mode 100644 index e6d17cc..0000000 --- a/ios/NightThoughts/Services/KeychainService.swift +++ /dev/null @@ -1,43 +0,0 @@ -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) - } -} diff --git a/ios/NightThoughts/Services/NotificationService.swift b/ios/NightThoughts/Services/NotificationService.swift deleted file mode 100644 index 095ddce..0000000 --- a/ios/NightThoughts/Services/NotificationService.swift +++ /dev/null @@ -1,16 +0,0 @@ -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() - } -} diff --git a/ios/NightThoughts/Services/RealtimeService.swift b/ios/NightThoughts/Services/RealtimeService.swift deleted file mode 100644 index bda51a6..0000000 --- a/ios/NightThoughts/Services/RealtimeService.swift +++ /dev/null @@ -1,86 +0,0 @@ -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 - ) -} diff --git a/ios/NightThoughts/Services/SupabaseService.swift b/ios/NightThoughts/Services/SupabaseService.swift deleted file mode 100644 index 4c4e9f3..0000000 --- a/ios/NightThoughts/Services/SupabaseService.swift +++ /dev/null @@ -1,379 +0,0 @@ -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 -} diff --git a/ios/NightThoughts/ViewModels/FeedViewModel.swift b/ios/NightThoughts/ViewModels/FeedViewModel.swift deleted file mode 100644 index ac14288..0000000 --- a/ios/NightThoughts/ViewModels/FeedViewModel.swift +++ /dev/null @@ -1,40 +0,0 @@ -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) - } -} diff --git a/ios/NightThoughts/ViewModels/ProfileViewModel.swift b/ios/NightThoughts/ViewModels/ProfileViewModel.swift deleted file mode 100644 index c94343e..0000000 --- a/ios/NightThoughts/ViewModels/ProfileViewModel.swift +++ /dev/null @@ -1,33 +0,0 @@ -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 - } - } -} diff --git a/ios/NightThoughts/Views/ComposeView.swift b/ios/NightThoughts/Views/ComposeView.swift deleted file mode 100644 index d9752c8..0000000 --- a/ios/NightThoughts/Views/ComposeView.swift +++ /dev/null @@ -1,320 +0,0 @@ -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" - } -} diff --git a/ios/NightThoughts/Views/DiaryView.swift b/ios/NightThoughts/Views/DiaryView.swift deleted file mode 100644 index b3c6b4a..0000000 --- a/ios/NightThoughts/Views/DiaryView.swift +++ /dev/null @@ -1,287 +0,0 @@ -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() - } -} diff --git a/ios/NightThoughts/Views/FeedView.swift b/ios/NightThoughts/Views/FeedView.swift deleted file mode 100644 index 3e5fe36..0000000 --- a/ios/NightThoughts/Views/FeedView.swift +++ /dev/null @@ -1,385 +0,0 @@ -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) - } -} diff --git a/ios/NightThoughts/Views/LegalView.swift b/ios/NightThoughts/Views/LegalView.swift deleted file mode 100644 index 182ecb5..0000000 --- a/ios/NightThoughts/Views/LegalView.swift +++ /dev/null @@ -1,232 +0,0 @@ -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: 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) - } - } -} diff --git a/ios/NightThoughts/Views/MainTabView.swift b/ios/NightThoughts/Views/MainTabView.swift deleted file mode 100644 index 0db34df..0000000 --- a/ios/NightThoughts/Views/MainTabView.swift +++ /dev/null @@ -1,199 +0,0 @@ -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" - } - } -} diff --git a/ios/NightThoughts/Views/OnboardingView.swift b/ios/NightThoughts/Views/OnboardingView.swift deleted file mode 100644 index d6ec48f..0000000 --- a/ios/NightThoughts/Views/OnboardingView.swift +++ /dev/null @@ -1,306 +0,0 @@ -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 } - } - } -} diff --git a/ios/NightThoughts/Views/PostRowView.swift b/ios/NightThoughts/Views/PostRowView.swift deleted file mode 100644 index 9e4dfab..0000000 --- a/ios/NightThoughts/Views/PostRowView.swift +++ /dev/null @@ -1,291 +0,0 @@ -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 - } - } -} diff --git a/ios/NightThoughts/Views/ProfileView.swift b/ios/NightThoughts/Views/ProfileView.swift deleted file mode 100644 index 3507590..0000000 --- a/ios/NightThoughts/Views/ProfileView.swift +++ /dev/null @@ -1,214 +0,0 @@ -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) - } -} diff --git a/ios/NightThoughts/Views/RootView.swift b/ios/NightThoughts/Views/RootView.swift deleted file mode 100644 index 899171f..0000000 --- a/ios/NightThoughts/Views/RootView.swift +++ /dev/null @@ -1,16 +0,0 @@ -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) - } -} diff --git a/ios/NightThoughts/Views/SettingsView.swift b/ios/NightThoughts/Views/SettingsView.swift deleted file mode 100644 index 87ade34..0000000 --- a/ios/NightThoughts/Views/SettingsView.swift +++ /dev/null @@ -1,243 +0,0 @@ -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]) - } -} diff --git a/ios/thoughts/.gemini/settings.json b/ios/thoughts/.gemini/settings.json new file mode 100644 index 0000000..36d25cb --- /dev/null +++ b/ios/thoughts/.gemini/settings.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "xcode-tools": { + "command": "xcrun", + "args": [ + "mcpbridge" + ] + } + } +} \ No newline at end of file diff --git a/ios/thoughts/SUPABASE_EMAIL_BRANDING.md b/ios/thoughts/SUPABASE_EMAIL_BRANDING.md new file mode 100644 index 0000000..569c98c --- /dev/null +++ b/ios/thoughts/SUPABASE_EMAIL_BRANDING.md @@ -0,0 +1,42 @@ +# 📧 Supabase Email Branding (Recovery) + +Kopiere diese Einstellungen in dein Supabase Dashboard unter: +**Authentication -> Email Templates -> Reset Password** + +### 1. Subject (Betreff) +`[thoughts] System Recovery: Zugriff wiederherstellen` + +### 2. Message (HTML Template) +```html + + + +
+

SYSTEM_RECOVERY

+

+ Es wurde eine Anfrage zum Zurücksetzen deines Passworts für **thoughts** empfangen. +

+ +

+ Dieser Link ist für 60 Minuten gültig.
+ Falls du das nicht warst, ignoriere diese E-Mail einfach. +

+
+

+ nightly.tech // encrypted_communication +

+
+ + +``` + +### 3. Deep Link Konfiguration +Damit der Link die App öffnet, musst du unter: +**Authentication -> URL Configuration** + +`thoughts://auth/reset` zu den **Redirect URLs** hinzufügen. diff --git a/ios/thoughts/SUPABASE_SECURITY.sql b/ios/thoughts/SUPABASE_SECURITY.sql new file mode 100644 index 0000000..fb7ce1c --- /dev/null +++ b/ios/thoughts/SUPABASE_SECURITY.sql @@ -0,0 +1,43 @@ +-- 🛡️ Supabase Security & RLS Setup +-- Führe diese Befehle in deinem Supabase SQL Editor aus! + +-- 1. Enable RLS for all tables +ALTER TABLE profiles ENABLE ROW LEVEL SECURITY; +ALTER TABLE posts ENABLE ROW LEVEL SECURITY; +ALTER TABLE resonances ENABLE ROW LEVEL SECURITY; + +-- 2. Profiles: Jeder kann Profile sehen, aber nur das eigene bearbeiten +CREATE POLICY "Profiles are viewable by everyone" +ON profiles FOR SELECT USING (true); + +CREATE POLICY "Users can update own profile" +ON profiles FOR UPDATE USING (auth.uid() = id); + +-- 3. Posts: Jeder kann Posts sehen, aber nur eigene erstellen/löschen +CREATE POLICY "Posts are viewable by everyone" +ON posts FOR SELECT USING (deleted_at IS NULL); + +CREATE POLICY "Users can create own posts" +ON posts FOR INSERT WITH CHECK (auth.uid() = author_id); + +CREATE POLICY "Users can soft-delete own posts" +ON posts FOR UPDATE USING (auth.uid() = author_id) +WITH CHECK (deleted_at IS NOT NULL); + +-- 4. Resonances: Nur eigene Reaktionen erstellen/löschen +CREATE POLICY "Resonances are viewable by everyone" +ON resonances FOR SELECT USING (true); + +CREATE POLICY "Users can toggle own resonances" +ON resonances FOR INSERT WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can delete own resonances" +ON resonances FOR DELETE USING (auth.uid() = user_id); + +-- 5. Helper function for deleteAccount (DSGVO) +CREATE OR REPLACE FUNCTION delete_my_account() +RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $$ +BEGIN + DELETE FROM auth.users WHERE id = auth.uid(); +END; +$$; diff --git a/ios/thoughts/thoughts.xcodeproj/project.pbxproj b/ios/thoughts/thoughts.xcodeproj/project.pbxproj index 4fa4f9f..b637044 100644 --- a/ios/thoughts/thoughts.xcodeproj/project.pbxproj +++ b/ios/thoughts/thoughts.xcodeproj/project.pbxproj @@ -301,6 +301,8 @@ /* Begin XCBuildConfiguration section */ 95576B6B2F98D4200029BE54 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 95576B482F98D41F0029BE54 /* thoughts */; + baseConfigurationReferenceRelativePath = Config.xcconfig; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -335,6 +337,8 @@ }; 95576B6C2F98D4200029BE54 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 95576B482F98D41F0029BE54 /* thoughts */; + baseConfigurationReferenceRelativePath = Config.xcconfig; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -369,6 +373,8 @@ }; 95576B6D2F98D4200029BE54 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 95576B482F98D41F0029BE54 /* thoughts */; + baseConfigurationReferenceRelativePath = Config.xcconfig; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -433,6 +439,8 @@ }; 95576B6E2F98D4200029BE54 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 95576B482F98D41F0029BE54 /* thoughts */; + baseConfigurationReferenceRelativePath = Config.xcconfig; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -490,6 +498,8 @@ }; 95576B702F98D4200029BE54 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 95576B482F98D41F0029BE54 /* thoughts */; + baseConfigurationReferenceRelativePath = Config.xcconfig; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -512,6 +522,8 @@ }; 95576B712F98D4200029BE54 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 95576B482F98D41F0029BE54 /* thoughts */; + baseConfigurationReferenceRelativePath = Config.xcconfig; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -534,6 +546,8 @@ }; 95576B732F98D4200029BE54 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 95576B482F98D41F0029BE54 /* thoughts */; + baseConfigurationReferenceRelativePath = Config.xcconfig; buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -554,6 +568,8 @@ }; 95576B742F98D4200029BE54 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 95576B482F98D41F0029BE54 /* thoughts */; + baseConfigurationReferenceRelativePath = Config.xcconfig; buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; diff --git a/ios/thoughts/thoughts/AppState.swift b/ios/thoughts/thoughts/AppState.swift index 9c41528..c0a4559 100644 --- a/ios/thoughts/thoughts/AppState.swift +++ b/ios/thoughts/thoughts/AppState.swift @@ -2,16 +2,67 @@ import Combine import SwiftUI import Supabase +extension Date { + var isWeekend: Bool { + let weekday = Calendar.current.component(.weekday, from: self) + return weekday == 1 || weekday == 7 // Sunday = 1, Saturday = 7 + } +} + @MainActor class AppState: ObservableObject { @Published var isAuthenticated = false @Published var currentUser: User? @Published var windowState: WindowState = .closed + @Published var showResetPassword = false private var windowTimer: Timer? enum WindowState { case closed, open, posted, missed } + var canPost: Bool { windowState == .open } + + // Smart window: considers weekday/weekend and user patterns + static func isInNightWindow(at date: Date = Date(), bypass: Bool = false) -> Bool { + if bypass { return true } + let hour = Calendar.current.component(.hour, from: date) + let isWeekend = date.isWeekend + || (Calendar.current.component(.weekday, from: date) == 6 + && hour >= 22) + || (Calendar.current.component(.weekday, from: date) == 1 + && hour < 9) + // Weekend: 22:00 - 07:00 (next day) + if isWeekend { + return hour >= 22 || hour < 7 + } + // Weekday: 23:00 - 06:00 + return hour >= 23 || hour < 6 + } + + static func windowCloseTime(at date: Date = Date()) -> Date { + let cal = Calendar.current + let isWeekend = cal.component(.weekday, from: date) == 7 + || cal.component(.weekday, from: date) == 1 + || (cal.component(.weekday, from: date) == 6 + && cal.component(.hour, from: date) >= 22) + let targetHour = isWeekend ? 7 : 6 + var components = cal.dateComponents([.year, .month, .day], from: date) + if cal.component(.hour, from: date) >= (isWeekend ? 22 : 23) { + // Already in window, close time is tomorrow morning + components = cal.dateComponents([.year, .month, .day], from: date) + } else if cal.component(.hour, from: date) < targetHour { + // Already in window (early morning) + components = cal.dateComponents([.year, .month, .day], from: date) + } else { + // Before window: close time is next morning + components.day! += 1 + } + components.hour = targetHour + components.minute = 0 + components.second = 0 + return cal.date(from: components) ?? date + } + init() { Task { await checkSession() } startWindowTimer() @@ -34,6 +85,7 @@ class AppState: ObservableObject { return } catch { print("[DEBUG] Auto-Login fehlgeschlagen: \(error.localizedDescription)") + print("[DEBUG] Stelle sicher, dass der Dev-Account (\(DevCredentials.email)) in Supabase existiert.") } #endif isAuthenticated = false @@ -64,9 +116,9 @@ class AppState: ObservableObject { func signOut() { Task { try? await supabase.auth.signOut() + isAuthenticated = false + currentUser = nil } - isAuthenticated = false - currentUser = nil } func deleteAccount() async throws { @@ -75,24 +127,43 @@ class AppState: ObservableObject { currentUser = nil } + func resetPassword(email: String) async throws { + try await supabase.resetPassword(email: email) + } + + func updatePassword(newPassword: String) async throws { + try await supabase.updatePassword(newPassword: newPassword) + isAuthenticated = true + let session = try await supabase.auth.session + await loadProfile(userId: session.user.id) + } + 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 - ) + do { + let profile = try await supabase.getMyProfile() + 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, + isPro: profile.isPro, + isAdmin: profile.isAdmin + ) + print("[DEBUG] Profil erfolgreich geladen: @\(profile.username)") + } catch { + print("[DEBUG] Profil-Laden fehlgeschlagen: \(error.localizedDescription)") + currentUser = nil + } } private func observeAuthChanges() { Task { - for await (event, session) in await supabase.auth.authStateChanges { + for await (event, session) in supabase.auth.authStateChanges { switch event { case .signedIn: if let session { @@ -112,13 +183,28 @@ class AppState: ObservableObject { // MARK: - Window State func updateWindowState() { - let hour = Calendar.current.component(.hour, from: Date()) - guard hour >= 2 && hour < 5 else { windowState = .closed; return } + if AppSettings.shared.bypassWindowLogic { + windowState = .open + return + } - let hasPosted = UserDefaults.standard.object(forKey: "lastPostDate") - .flatMap { $0 as? Date } - .map { Calendar.current.isDateInToday($0) } ?? false - windowState = hasPosted ? .posted : .open + let inWindow = Self.isInNightWindow(at: Date(), bypass: false) + if !inWindow { + let lastPost = UserDefaults.standard.object(forKey: "lastPostDate") as? Date + if let last = lastPost, Calendar.current.isDateInToday(last) { + windowState = .posted + } else { + windowState = .closed + } + return + } + + let lastPost = UserDefaults.standard.object(forKey: "lastPostDate") as? Date + if let last = lastPost, Calendar.current.isDateInToday(last) { + windowState = .posted + } else { + windowState = .open + } } func markAsPosted() { diff --git a/ios/thoughts/thoughts/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/thoughts/thoughts/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..24cada9 100644 --- a/ios/thoughts/thoughts/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/thoughts/thoughts/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -12,6 +13,7 @@ "value" : "dark" } ], + "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -23,6 +25,7 @@ "value" : "tinted" } ], + "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/ios/thoughts/thoughts/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/ios/thoughts/thoughts/Assets.xcassets/AppIcon.appiconset/icon_1024.png new file mode 100644 index 0000000..428d2b6 Binary files /dev/null and b/ios/thoughts/thoughts/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ diff --git a/ios/thoughts/thoughts/Extensions/Colors.swift b/ios/thoughts/thoughts/Extensions/Colors.swift index c16e459..86c8568 100644 --- a/ios/thoughts/thoughts/Extensions/Colors.swift +++ b/ios/thoughts/thoughts/Extensions/Colors.swift @@ -3,22 +3,22 @@ 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) + // Backgrounds — deeper, softer tones + static let nightBase = Color(hex: "030308") // Haupt-Hintergrund + static let nightSurface = Color(hex: "0A0A15") // Karten, Sheets + static let nightRaised = Color(hex: "121225") // Elevated surfaces + static let nightBorder = Color(white: 1, opacity: 0.1) // Text - static let nightPrimary = Color(hex: "EEEEF8") - static let nightSecondary = Color(hex: "64647A") - static let nightTertiary = Color(hex: "3A3A52") + static let nightPrimary = Color(hex: "F8F8FF") + static let nightSecondary = Color(hex: "A0A0B8") + static let nightTertiary = Color(hex: "5A5A75") - // 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") + // Akzente — softer ethereal pastel hues + static let nightPurple = Color(hex: "8E74FF") + static let nightPurpleSoft = Color(hex: "B4A5FF") + static let nightGreen = Color(hex: "5EEAD4") + static let nightRed = Color(hex: "FF6B6B") // Hex initializer init(hex: String) { @@ -44,10 +44,12 @@ extension Color { 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") + case .still: return Color(hex: "818CF8") + case .unruhig: return Color(hex: "FB923C") + case .schwer: return Color(hex: "A78BFA") + case .geladen: return Color(hex: "2DD4BF") + case .zart: return Color(hex: "F9A8D4") + case .dunkel: return Color(hex: "9CA3AF") } } var label: String { rawValue } @@ -55,8 +57,30 @@ extension Mood { switch self { case .still: return "◌" case .unruhig: return "◎" - case .melancholisch: return "◑" - case .aufgedreht: return "◉" + case .schwer: return "◑" + case .geladen: return "◉" + case .zart: return "✿" + case .dunkel: return "◈" + } + } + var description: String { + switch self { + case .still: return "friedlich, ruhig, angekommen" + case .unruhig: return "Gedanken drehen sich, can't sleep" + case .schwer: return "wehmütig, melancholisch, nostalgia" + case .geladen: return "aufgedreht, energetic, wach" + case .zart: return "sanft, hoffnungsvoll, verletzlich" + case .dunkel: return "raw, heavy, ehrlich" + } + } + var colorGradient: [Color] { + switch self { + case .still: return [Color(hex: "818CF8"), Color(hex: "6366F1")] + case .unruhig: return [Color(hex: "FB923C"), Color(hex: "F59E0B")] + case .schwer: return [Color(hex: "A78BFA"), Color(hex: "7C3AED")] + case .geladen: return [Color(hex: "2DD4BF"), Color(hex: "14B8A6")] + case .zart: return [Color(hex: "F9A8D4"), Color(hex: "EC4899")] + case .dunkel: return [Color(hex: "9CA3AF"), Color(hex: "6B7280")] } } } @@ -65,15 +89,15 @@ extension Mood { extension Font { static func nightTitle(_ size: CGFloat) -> Font { - .system(size: size, weight: .bold, design: .rounded) + .system(size: size, weight: .semibold, design: .serif) } static func nightBody(_ size: CGFloat) -> Font { - .system(size: size, weight: .regular) + .system(size: size, weight: .light, design: .default) } - static func nightMono(_ size: CGFloat) -> Font { - .system(size: size, design: .monospaced) + static func nightSerif(_ size: CGFloat) -> Font { + .system(size: size, design: .serif) } static func nightLabel(_ size: CGFloat, weight: Font.Weight = .medium) -> Font { - .system(size: size, weight: weight) + .system(size: size, weight: weight, design: .rounded) } } diff --git a/ios/thoughts/thoughts/Extensions/Haptics.swift b/ios/thoughts/thoughts/Extensions/Haptics.swift index e107790..7bb1843 100644 --- a/ios/thoughts/thoughts/Extensions/Haptics.swift +++ b/ios/thoughts/thoughts/Extensions/Haptics.swift @@ -8,23 +8,65 @@ enum Haptics { private static let notification = UINotificationFeedbackGenerator() /// Leichtes Feedback — Resonance-Button, Follow - static func light() { lightImpact.impactOccurred() } + static func light() { + #if targetEnvironment(simulator) + return + #endif + guard AppSettings.shared.hapticsEnabled else { return } + lightImpact.impactOccurred(intensity: AppSettings.shared.hapticsIntensity * 0.7) + } /// Mittleres Feedback — Abmelden, wichtige Aktionen - static func medium() { mediumImpact.impactOccurred() } + static func medium() { + #if targetEnvironment(simulator) + return + #endif + guard AppSettings.shared.hapticsEnabled else { return } + mediumImpact.impactOccurred(intensity: AppSettings.shared.hapticsIntensity) + } /// Ganz sanftes Feedback — Mood-Auswahl - static func soft() { lightImpact.impactOccurred(intensity: 0.45) } + static func soft() { + #if targetEnvironment(simulator) + return + #endif + guard AppSettings.shared.hapticsEnabled else { return } + lightImpact.impactOccurred(intensity: AppSettings.shared.hapticsIntensity * 0.45) + } /// Selection-Feedback — Tab-Wechsel, Toggles - static func select() { selection.selectionChanged() } + static func select() { + #if targetEnvironment(simulator) + return + #endif + guard AppSettings.shared.hapticsEnabled else { return } + selection.selectionChanged() + } /// Erfolg — Post gesendet, Follow erfolgreich - static func success() { notification.notificationOccurred(.success) } + static func success() { + #if targetEnvironment(simulator) + return + #endif + guard AppSettings.shared.hapticsEnabled else { return } + notification.notificationOccurred(.success) + } /// Warnung — Account löschen - static func warning() { notification.notificationOccurred(.warning) } + static func warning() { + #if targetEnvironment(simulator) + return + #endif + guard AppSettings.shared.hapticsEnabled else { return } + notification.notificationOccurred(.warning) + } /// Fehler — Post fehlgeschlagen - static func error() { notification.notificationOccurred(.error) } + static func error() { + #if targetEnvironment(simulator) + return + #endif + guard AppSettings.shared.hapticsEnabled else { return } + notification.notificationOccurred(.error) + } } diff --git a/ios/thoughts/thoughts/Extensions/ShimmerModifier.swift b/ios/thoughts/thoughts/Extensions/ShimmerModifier.swift index a8a1539..3d22641 100644 --- a/ios/thoughts/thoughts/Extensions/ShimmerModifier.swift +++ b/ios/thoughts/thoughts/Extensions/ShimmerModifier.swift @@ -1,8 +1,5 @@ import SwiftUI -// MARK: - Shimmer Modifier - -/// Gleitender Lichteffekt über Skeleton-Elemente. struct ShimmerModifier: ViewModifier { @State private var phase: CGFloat = -1 @@ -40,47 +37,40 @@ extension View { } } -// MARK: - Skeleton Post Row - -/// Platzhalter-Zeile die aussieht wie ein echter Post. struct SkeletonPostRow: View { var body: some View { - HStack(alignment: .top, spacing: 0) { - // Mood-Accent-Bar + HStack(spacing: 0) { RoundedRectangle(cornerRadius: 1) - .fill(Color.nightTertiary.opacity(0.2)) - .frame(width: 2) - .padding(.vertical, 18) + .fill(Color.nightTertiary.opacity(0.12)) + .frame(width: 2.5) + .padding(.vertical, 16) - VStack(alignment: .leading, spacing: 11) { - // Avatar + Name + Zeitstempel - HStack(spacing: 9) { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { Circle() .fill(Color.nightRaised) - .frame(width: 32, height: 32) + .frame(width: 28, height: 28) RoundedRectangle(cornerRadius: 4) .fill(Color.nightRaised) - .frame(width: 90, height: 13) + .frame(width: 80, height: 12) Spacer() RoundedRectangle(cornerRadius: 4) .fill(Color.nightRaised) - .frame(width: 36, height: 11) + .frame(width: 30, height: 10) } - // Content-Zeilen - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 5) { RoundedRectangle(cornerRadius: 4) .fill(Color.nightRaised) - .frame(height: 13) + .frame(height: 12) RoundedRectangle(cornerRadius: 4) .fill(Color.nightRaised) - .frame(width: 180, height: 13) + .frame(width: 160, height: 12) } - // Resonance-Button Platzhalter RoundedRectangle(cornerRadius: 8) .fill(Color.nightRaised) - .frame(width: 90, height: 24) + .frame(width: 80, height: 22) } .padding(.leading, 14) .padding(.trailing, 16) @@ -90,18 +80,13 @@ struct SkeletonPostRow: View { } } -// MARK: - Skeleton Profile Header - -/// Platzhalter für den Profilkopf beim Laden. struct SkeletonProfileHeader: View { var body: some View { VStack(spacing: 16) { - // Avatar Circle() .fill(Color.nightRaised) .frame(width: 76, height: 76) - // Name + Username VStack(spacing: 8) { RoundedRectangle(cornerRadius: 4) .fill(Color.nightRaised) @@ -111,7 +96,6 @@ struct SkeletonProfileHeader: View { .frame(width: 80, height: 13) } - // Stats HStack(spacing: 36) { ForEach(0..<3, id: \.self) { _ in VStack(spacing: 4) { @@ -125,7 +109,6 @@ struct SkeletonProfileHeader: View { } } - // Action Button RoundedRectangle(cornerRadius: 10) .fill(Color.nightRaised) .frame(height: 40) @@ -135,4 +118,4 @@ struct SkeletonProfileHeader: View { .padding(.bottom, 20) .shimmer() } -} +} \ No newline at end of file diff --git a/ios/thoughts/thoughts/Info.plist b/ios/thoughts/thoughts/Info.plist index 74a91d6..8ecce20 100644 --- a/ios/thoughts/thoughts/Info.plist +++ b/ios/thoughts/thoughts/Info.plist @@ -2,6 +2,19 @@ + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + dk0.dev.thoughts + CFBundleURLSchemes + + thoughts + + + SUPABASE_URL $(SUPABASE_URL) SUPABASE_ANON_KEY diff --git a/ios/thoughts/thoughts/Models/AppSettings.swift b/ios/thoughts/thoughts/Models/AppSettings.swift new file mode 100644 index 0000000..436817b --- /dev/null +++ b/ios/thoughts/thoughts/Models/AppSettings.swift @@ -0,0 +1,13 @@ +import SwiftUI +import Combine + +class AppSettings: ObservableObject { + @AppStorage("hapticsEnabled") var hapticsEnabled: Bool = true + @AppStorage("hapticsIntensity") var hapticsIntensity: Double = 1.0 + @AppStorage("fluidAnimations") var fluidAnimations: Bool = true + @AppStorage("bypassWindowLogic") var bypassWindowLogic: Bool = false + + static let shared = AppSettings() + + private init() {} +} diff --git a/ios/thoughts/thoughts/Models/Post.swift b/ios/thoughts/thoughts/Models/Post.swift index 8c6012f..249902b 100644 --- a/ios/thoughts/thoughts/Models/Post.swift +++ b/ios/thoughts/thoughts/Models/Post.swift @@ -5,9 +5,11 @@ import Foundation enum Mood: String, Codable, CaseIterable { case still = "still" case unruhig = "unruhig" - case melancholisch = "melancholisch" - case aufgedreht = "aufgedreht" - // color, label, emoji → Colors.swift extension + case schwer = "schwer" + case geladen = "geladen" + case zart = "zart" + case dunkel = "dunkel" + // color, label, emoji, description → Colors.swift extension } // MARK: - Post @@ -18,25 +20,42 @@ struct Post: Identifiable, Codable { let content: String let mood: Mood? let createdAt: Date - var resonanceCount: Int // "Hat mich getroffen" - var hasResonated: Bool // Current user's reaction + var resonanceCount: Int + var hasResonated: Bool var commentCount: Int let isAnonymous: Bool let nightOf: Date + let hasContentWarning: Bool + let contentWarningType: ContentWarningType? + + static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f + }() + + static let nightDateFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "de_DE") + f.dateFormat = "EEEE, d. MMMM yyyy" + return f + }() 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) + Self.dateFormatter.string(from: createdAt) + } + + var formattedNightDate: String { + let nightDate = createdAt.addingTimeInterval(-2 * 3600) + return Self.nightDateFormatter.string(from: nightDate) } var timeAgo: String { @@ -51,20 +70,23 @@ struct Post: Identifiable, Codable { id: "1", author: .preview, content: "warum denk ich um 3 uhr morgens noch an das was ich 2019 gesagt hab", - mood: .melancholisch, + mood: .schwer, createdAt: Date().addingTimeInterval(-180), resonanceCount: 12, hasResonated: false, commentCount: 3, isAnonymous: false, - nightOf: Date() + nightOf: Date(), + hasContentWarning: false, + contentWarningType: nil ), 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 + followerCount: 88, followingCount: 44, postCount: 12, isFollowing: true, + isPro: false, isAdmin: false ), content: "das licht vom handy macht alles schlimmer aber ich leg es trotzdem nicht weg", mood: .unruhig, @@ -73,35 +95,186 @@ struct Post: Identifiable, Codable { hasResonated: true, commentCount: 1, isAnonymous: false, - nightOf: Date() + nightOf: Date(), + hasContentWarning: false, + contentWarningType: nil ), 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, + mood: .schwer, createdAt: Date().addingTimeInterval(-300), resonanceCount: 31, hasResonated: true, commentCount: 7, isAnonymous: true, - nightOf: Date() + nightOf: Date(), + hasContentWarning: false, + contentWarningType: nil ), Post( id: "4", author: User( id: "4", username: "felix.nacht", displayName: "Felix", bio: nil, avatarURL: nil, - followerCount: 33, followingCount: 20, postCount: 8, isFollowing: false + followerCount: 33, followingCount: 20, postCount: 8, isFollowing: false, + isPro: false, isAdmin: false ), content: "hab gerade realisiert dass ich seit 4 stunden auf tiktok bin und morgen um 7 aufstehen muss", - mood: .aufgedreht, + mood: .geladen, createdAt: Date().addingTimeInterval(-60), resonanceCount: 5, hasResonated: false, commentCount: 0, isAnonymous: false, - nightOf: Date() + nightOf: Date(), + hasContentWarning: false, + contentWarningType: nil + ), + Post( + id: "5", + author: User( + id: "5", username: "nachtvogel_", displayName: "Nachtvogel", + bio: "3 Uhr ist meine goldene Stunde", avatarURL: nil, + followerCount: 214, followingCount: 67, postCount: 43, isFollowing: true, + isPro: false, isAdmin: false + ), + content: "manchmal ist die stille nachts lauter als alle geräusche tagsüber", + mood: .still, + createdAt: Date().addingTimeInterval(-2400), + resonanceCount: 47, + hasResonated: true, + commentCount: 5, + isAnonymous: false, + nightOf: Date().addingTimeInterval(-86400), + hasContentWarning: false, + contentWarningType: nil + ), + Post( + id: "6", + author: .anonymousPlaceholder, + content: "ich hab meiner besten freundin gesagt dass ich sie vermisse und sie hat nur geantwortet mit einem smiley", + mood: .zart, + createdAt: Date().addingTimeInterval(-5400), + resonanceCount: 23, + hasResonated: false, + commentCount: 2, + isAnonymous: true, + nightOf: Date().addingTimeInterval(-86400), + hasContentWarning: false, + contentWarningType: nil + ), + Post( + id: "7", + author: User( + id: "7", username: "4am_thoughts", displayName: "Lena", + bio: nil, avatarURL: nil, + followerCount: 156, followingCount: 91, postCount: 27, isFollowing: false, + isPro: false, isAdmin: false + ), + content: "traum von einer stadt in der niemand schläft und alle auf den dächern sitzen und in den himmel schauen", + mood: .zart, + createdAt: Date().addingTimeInterval(-7200), + resonanceCount: 19, + hasResonated: true, + commentCount: 3, + isAnonymous: false, + nightOf: Date(), + hasContentWarning: false, + contentWarningType: nil + ), + Post( + id: "8", + author: .anonymousPlaceholder, + content: "ich kann nicht aufhören an ihn zu denken und das schlimmste ist ich weiß dass es falsch ist", + mood: .dunkel, + createdAt: Date().addingTimeInterval(-10800), + resonanceCount: 41, + hasResonated: false, + commentCount: 0, + isAnonymous: true, + nightOf: Date(), + hasContentWarning: false, + contentWarningType: nil + ), + Post( + id: "9", + author: User( + id: "9", username: "mondkind", displayName: "Mondkind", + bio: nil, avatarURL: nil, + followerCount: 72, followingCount: 38, postCount: 15, isFollowing: true, + isPro: true, isAdmin: false + ), + content: "regentropfen am fenster sind das einzigste was mich gerade noch companie macht", + mood: .still, + createdAt: Date().addingTimeInterval(-1800), + resonanceCount: 14, + hasResonated: false, + commentCount: 1, + isAnonymous: false, + nightOf: Date(), + hasContentWarning: false, + contentWarningType: nil + ), + Post( + id: "10", + author: User( + id: "10", username: "keinschlaf_", displayName: "k.einschlaf", + bio: nil, avatarURL: nil, + followerCount: 59, followingCount: 22, postCount: 31, isFollowing: false, + isPro: false, isAdmin: false + ), + content: "hab mein zimmer aufgeräumt statt zu schlafen. zumindest irgendetwas produktives heute nacht", + mood: .geladen, + createdAt: Date().addingTimeInterval(-300), + resonanceCount: 3, + hasResonated: false, + commentCount: 0, + isAnonymous: false, + nightOf: Date(), + hasContentWarning: true, + contentWarningType: .other ) ] } + +// MARK: - Content Warning + +enum ContentWarningType: String, Codable, CaseIterable { + case selfharm = "selfharm" + case violence = "violence" + case eating = "eating" + case substance = "substance" + case other = "other" + + var label: String { + switch self { + case .selfharm: return "Selbstverletzung / Suizidgedanken" + case .violence: return "Gewalt" + case .eating: return "Essstörungen" + case .substance: return "Substanzmissbrauch" + case .other: return "Anderes" + } + } + + var shortLabel: String { + switch self { + case .selfharm: return "Achtung: Selbstverletzung" + case .violence: return "Achtung: Gewalt" + case .eating: return "Achtung: Essstörung" + case .substance: return "Achtung: Substanz" + case .other: return "Achtung" + } + } + + var icon: String { + switch self { + case .selfharm: return "exclamationmark.triangle" + case .violence: return "exclamationmark.octagon" + case .eating: return "exclamationmark.shield" + case .substance: return "exclamationmark.bubble" + case .other: return "exclamationmark.circle" + } + } +} \ No newline at end of file diff --git a/ios/thoughts/thoughts/Models/User.swift b/ios/thoughts/thoughts/Models/User.swift index 7ada9b0..943e936 100644 --- a/ios/thoughts/thoughts/Models/User.swift +++ b/ios/thoughts/thoughts/Models/User.swift @@ -10,6 +10,8 @@ struct User: Identifiable, Codable, Equatable { var followingCount: Int var postCount: Int var isFollowing: Bool + var isPro: Bool + var isAdmin: Bool static let preview = User( id: "preview", @@ -20,6 +22,8 @@ struct User: Identifiable, Codable, Equatable { followerCount: 142, followingCount: 89, postCount: 37, - isFollowing: false + isFollowing: false, + isPro: true, + isAdmin: false ) } diff --git a/ios/thoughts/thoughts/Secrets.swift b/ios/thoughts/thoughts/Secrets.swift index 606f503..b17ca0a 100644 --- a/ios/thoughts/thoughts/Secrets.swift +++ b/ios/thoughts/thoughts/Secrets.swift @@ -14,8 +14,8 @@ import Foundation #if DEBUG enum DevCredentials { - static let email = "dev@nightly.test" - static let password = "TestPassword123!" + static let email = "info@dk0.dev" + static let password = "test123" } #endif @@ -27,7 +27,7 @@ enum Config { let url = URL(string: raw) else { // Fallback für Entwicklung — ersetze mit deiner URL - return URL(string: "https://api.xxx.dk0.dev")! + return URL(string: "https://thoughts.dk0.dev")! } return url }() diff --git a/ios/thoughts/thoughts/Services/RealtimeService.swift b/ios/thoughts/thoughts/Services/RealtimeService.swift index f11c370..f23bb7b 100644 --- a/ios/thoughts/thoughts/Services/RealtimeService.swift +++ b/ios/thoughts/thoughts/Services/RealtimeService.swift @@ -2,8 +2,6 @@ import Combine import Foundation import Supabase -/// Verwaltet die Echtzeit-Verbindung für "Gerade Jetzt". -/// Neue Posts erscheinen sofort ohne Polling. @MainActor class RealtimeService: ObservableObject { @Published var newPostsCount = 0 @@ -15,19 +13,17 @@ class RealtimeService: ObservableObject { self.onNewPost = onNewPost guard channel == nil else { return } - let ch = await supabase.channel("public:posts") + let ch = supabase.channel("public:posts") - // Neue Posts in Echtzeit empfangen - let stream = await ch.postgresChange( + let stream = ch.postgresChange( InsertAction.self, schema: "public", table: "posts" ) - await ch.subscribe() + try? await ch.subscribeWithError() self.channel = ch - // Stream im Hintergrund konsumieren Task { [weak self] in for await action in stream { await self?.handleInsert(action) @@ -36,29 +32,45 @@ class RealtimeService: ObservableObject { } func stopListening() async { - if let ch = channel { - await supabase.removeChannel(ch) - channel = nil - } + guard let ch = channel else { return } + await supabase.removeChannel(ch) + channel = nil } - private func handleInsert(_ action: InsertAction) { - // Den neuen Post aus dem Record dekodieren + private func handleInsert(_ action: InsertAction) async { 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 authorId = 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:)) + var author = User.anonymousPlaceholder + if !isAnon { + if let profile = try? await supabase.getProfile(userId: UUID(uuidString: authorId) ?? UUID()) { + author = User( + id: authorId, + username: profile.username, + displayName: profile.displayName, + bio: profile.bio, + avatarURL: profile.avatarUrl.flatMap(URL.init), + followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false, + isPro: profile.isPro, isAdmin: profile.isAdmin + ) + } + } + + let hasCW = action.record["has_content_warning"]?.boolValue ?? false + let cwType = action.record["content_warning_type"]?.stringValue.flatMap(ContentWarningType.init(rawValue:)) + let post = Post( id: id, - author: User.anonymousPlaceholder, // Profil wird lazily nachgeladen + author: author, content: content, mood: mood, createdAt: createdAt, @@ -66,7 +78,9 @@ class RealtimeService: ObservableObject { hasResonated: false, commentCount: 0, isAnonymous: isAnon, - nightOf: createdAt + nightOf: createdAt, + hasContentWarning: hasCW, + contentWarningType: cwType ) newPostsCount += 1 @@ -74,14 +88,13 @@ class RealtimeService: ObservableObject { } } -// 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 + followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false, + isPro: false, isAdmin: false ) -} +} \ No newline at end of file diff --git a/ios/thoughts/thoughts/Services/SupabaseService.swift b/ios/thoughts/thoughts/Services/SupabaseService.swift index ddb54ec..db5eda8 100644 --- a/ios/thoughts/thoughts/Services/SupabaseService.swift +++ b/ios/thoughts/thoughts/Services/SupabaseService.swift @@ -20,6 +20,9 @@ let supabase = SupabaseClient( d.dateDecodingStrategy = .iso8601 return d }() + ), + auth: .init( + emitLocalSessionAsInitialSession: true ) ) ) @@ -57,6 +60,14 @@ extension SupabaseClient { try await self.auth.signOut() } + func resetPassword(email: String) async throws { + try await self.auth.resetPasswordForEmail(email, redirectTo: URL(string: "thoughts://auth/reset")) + } + + func updatePassword(newPassword: String) async throws { + try await self.auth.update(user: .init(password: newPassword)) + } + /// Account vollständig löschen (DSGVO — löscht alles über DB-Funktion) func deleteAccount() async throws { try await self.rpc("delete_my_account").execute() @@ -94,6 +105,66 @@ extension SupabaseClient { .value } + func getPublicProfile(userId: UUID) async throws -> User { + struct PublicProfileRow: Decodable { + let id: UUID + let username: String + let displayName: String + let bio: String? + let avatarUrl: String? + let isPro: Bool + let isAdmin: Bool + let postCount: Int + let resonanceCount: Int + let followerCount: Int + let followingCount: Int + } + let row: PublicProfileRow = try await self + .from("public_profile") + .select() + .eq("id", value: userId) + .single() + .execute() + .value + return User( + id: row.id.uuidString, + username: row.username, + displayName: row.displayName, + bio: row.bio, + avatarURL: row.avatarUrl.flatMap(URL.init), + followerCount: row.followerCount, + followingCount: row.followingCount, + postCount: row.postCount, + isFollowing: false, + isPro: row.isPro, + isAdmin: row.isAdmin + ) + } + + struct ProfileStats: Decodable { + let postCount: Int + let resonanceCount: Int + } + + func getProfileStats(userId: UUID) async throws -> ProfileStats { + let postRows: [FeedPostRow] = (try? await self + .from("posts") + .select("id, content, mood, is_anonymous, created_at, resonance_count:resonances(count)") + .eq("user_id", value: userId) + .is("deleted_at", value: nil) + .execute() + .value) ?? [] + + let resonanceRows: [[String: Int]] = (try? await self + .rpc("get_resonance_count", params: ["p_user_id": userId.uuidString]) + .execute() + .value) ?? [] + + let resonanceCount = resonanceRows.first?["count"] ?? 0 + + return ProfileStats(postCount: postRows.count, resonanceCount: resonanceCount) + } + func updateProfile(displayName: String? = nil, bio: String? = nil) async throws { guard let uid = await currentUserId else { throw AuthError.notAuthenticated } var update: [String: String] = [:] @@ -124,12 +195,39 @@ extension SupabaseClient { extension SupabaseClient { - /// Feed: Posts der letzten 14h von gefollowten Usern + eigene + /// Feed: Posts der letzten 14h von gefollowten Usern + eigene + anonyme func getFeed() async throws -> [Post] { guard let uid = await currentUserId else { return [] } - // Erst die gefolgten User-IDs holen - // Supabase gibt Objekte zurück [{following_id:"uuid"}], kein [String] + let fourteenHoursAgo = Date().addingTimeInterval(-14 * 3600) + let cutoffISO = ISO8601DateFormatter().string(from: fourteenHoursAgo) + + let rows: [FeedPostRow] = try await self + .from("feed_posts") + .select() + .or("author_id.eq.\(uid.uuidString),is_anonymous.eq.true") + .gte("created_at", value: cutoffISO) + .order("created_at", ascending: false) + .limit(100) + .execute() + .value + + 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)) } + } + + /// Feed mit gefolgten Usern (sobald Follow-Feature aktiv) + func getFeedWithFollows() async throws -> [Post] { + guard let uid = await currentUserId else { return [] } + struct FollowRow: Decodable { let followingId: String } let followRows: [FollowRow] = try await self .from("follows") @@ -140,16 +238,19 @@ extension SupabaseClient { let allIds = followRows.map(\.followingId) + [uid.uuidString] + let fourteenHoursAgo = Date().addingTimeInterval(-14 * 3600) + let cutoffISO = ISO8601DateFormatter().string(from: fourteenHoursAgo) + let rows: [FeedPostRow] = try await self .from("feed_posts") .select() - .in("author_id", values: allIds) + .or("author_id.in.(\(allIds.joined(separator: ","))),is_anonymous.eq.true") + .gte("created_at", value: cutoffISO) .order("created_at", ascending: false) - .limit(150) + .limit(100) .execute() .value - // Eigene Resonances holen (RLS filtert, SDK gibt nur eigene zurück) let myResonances: [ResonanceRow] = (try? await self .from("resonances") .select("post_id") @@ -189,23 +290,48 @@ extension SupabaseClient { return rows.map { $0.toPost(hasResonated: false) } } - func createPost(content: String, mood: Mood, isAnonymous: Bool) async throws { + func createPost(content: String, mood: Mood, isAnonymous: Bool, hasContentWarning: Bool = false, contentWarningType: ContentWarningType? = nil) async throws -> String { guard let uid = await currentUserId else { throw AuthError.notAuthenticated } struct Params: Encodable { let userId: String let content: String let mood: String let isAnonymous: Bool + let hasContentWarning: Bool + let contentWarningType: String? } - try await self.from("posts").insert( - Params(userId: uid.uuidString, content: content, mood: mood.rawValue, isAnonymous: isAnonymous) - ).execute() + + struct InsertResponse: Decodable { let id: String } + + let response: InsertResponse = try await self.from("posts").insert( + Params( + userId: uid.uuidString, + content: content, + mood: mood.rawValue, + isAnonymous: isAnonymous, + hasContentWarning: hasContentWarning, + contentWarningType: contentWarningType?.rawValue + ) + ).select("id").single().execute().value + + return response.id + } + + func hardDeletePost(id: String) async throws { + try await self.from("posts").delete().eq("id", value: id).execute() + } + + func deleteAllMyPosts() async throws { + guard let uid = await currentUserId else { return } + try await self.from("posts").delete().eq("user_id", value: uid).execute() } func softDeletePost(id: String) async throws { + guard let uid = await currentUserId else { throw AuthError.notAuthenticated } try await self.from("posts") .update(["deleted_at": ISO8601DateFormatter().string(from: Date())]) .eq("id", value: id) + .eq("user_id", value: uid) .execute() } } @@ -250,6 +376,26 @@ extension SupabaseClient { .execute() } + func isFollowing(userId: UUID) async -> Bool { + guard let uid = await currentUserId else { return false } + do { + let rows: [FollowCheckRow] = try await self + .from("follows") + .select("following_id") + .eq("follower_id", value: uid) + .eq("following_id", value: userId) + .execute() + .value + return !rows.isEmpty + } catch { + return false + } + } + + private struct FollowCheckRow: Decodable { + let followingId: String + } + func getStreak(userId: UUID) async throws -> Int { // Nächte mit Posts — berechnet in SQL let rows: [[String: Int]] = (try? await self @@ -338,25 +484,34 @@ struct FeedPostRow: Decodable { 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? + let hasContentWarning: Bool? + let contentWarningType: String? func toPost(hasResonated: Bool) -> Post { - let author: User? = authorId.map { - User( - id: $0, - username: authorUsername ?? "?", - displayName: authorDisplayName ?? "?", + let author: User + if isAnonymous { + author = User.anonymousPlaceholder + } else if let aid = authorId { + author = User( + id: aid, + username: authorUsername ?? "user", + displayName: authorDisplayName ?? "User", bio: nil, avatarURL: authorAvatarUrl.flatMap(URL.init), - followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false + followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false, + isPro: false, isAdmin: false ) + } else { + author = User(id: "unknown", username: "user", displayName: "User", + bio: nil, avatarURL: nil, followerCount: 0, followingCount: 0, + postCount: 0, isFollowing: false, isPro: false, isAdmin: false) } return Post( id: id, - author: author ?? User.anonymousPlaceholder, + author: author, content: content, mood: mood.flatMap(Mood.init(rawValue:)), createdAt: createdAt, @@ -364,7 +519,9 @@ struct FeedPostRow: Decodable { hasResonated: hasResonated, commentCount: 0, isAnonymous: isAnonymous, - nightOf: createdAt + nightOf: createdAt, + hasContentWarning: hasContentWarning ?? false, + contentWarningType: contentWarningType.flatMap(ContentWarningType.init(rawValue:)) ) } } diff --git a/ios/thoughts/thoughts/ViewModels/FeedViewModel.swift b/ios/thoughts/thoughts/ViewModels/FeedViewModel.swift index f7ff381..c97e455 100644 --- a/ios/thoughts/thoughts/ViewModels/FeedViewModel.swift +++ b/ios/thoughts/thoughts/ViewModels/FeedViewModel.swift @@ -5,13 +5,18 @@ import Foundation class FeedViewModel: ObservableObject { @Published var posts: [Post] = [] @Published var isLoading = false + @Published var errorMessage: String? func load() async { - isLoading = true + if posts.isEmpty { + isLoading = true + } defer { isLoading = false } do { - posts = try await supabase.getFeed() + posts = try await supabase.getFeedWithFollows() + errorMessage = nil } catch { + errorMessage = "Feed konnte nicht geladen werden." #if DEBUG posts = Post.previews #endif @@ -33,9 +38,12 @@ class FeedViewModel: ObservableObject { } } - /// 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) } -} + + func removePost(id: String) { + posts.removeAll { $0.id == id } + } +} \ No newline at end of file diff --git a/ios/thoughts/thoughts/ViewModels/ProfileViewModel.swift b/ios/thoughts/thoughts/ViewModels/ProfileViewModel.swift deleted file mode 100644 index 28803b4..0000000 --- a/ios/thoughts/thoughts/ViewModels/ProfileViewModel.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Combine -import Foundation - -@MainActor -class ProfileViewModel: ObservableObject { - @Published var posts: [Post] = [] - @Published var streak: Int = 0 - @Published var isLoading = false - - let userId: UUID - - init(userId: UUID) { - self.userId = userId - } - - convenience init(userIdString: String) { - self.init(userId: UUID(uuidString: userIdString) ?? UUID()) - } - - func load() async { - isLoading = true - defer { isLoading = false } - do { - async let postsTask = supabase.getUserPosts(userId: userId) - async let streakTask = supabase.getStreak(userId: userId) - (posts, streak) = try await (postsTask, streakTask) - } catch { - #if DEBUG - posts = Post.previews - streak = 4 - #endif - } - } -} diff --git a/ios/thoughts/thoughts/Views/Auth/ResetPasswordView.swift b/ios/thoughts/thoughts/Views/Auth/ResetPasswordView.swift new file mode 100644 index 0000000..f5d94ac --- /dev/null +++ b/ios/thoughts/thoughts/Views/Auth/ResetPasswordView.swift @@ -0,0 +1,206 @@ +import SwiftUI + +struct ResetPasswordView: View { + @EnvironmentObject var appState: AppState + @Environment(\.dismiss) var dismiss + + @State private var password = "" + @State private var confirmPassword = "" + @State private var showPassword = false + @State private var isLoading = false + @State private var errorMessage: String? + @State private var success = false + + var strength: Double { + var score: Double = 0 + if password.count >= 8 { score += 0.25 } + if password.range(of: "[A-Z]", options: .regularExpression) != nil { score += 0.25 } + if password.range(of: "[0-9]", options: .regularExpression) != nil { score += 0.25 } + if password.range(of: "[!@#$%^&*(),.?\":{}|<>]", options: .regularExpression) != nil { score += 0.25 } + return score + } + + var strengthColor: Color { + if strength < 0.5 { return .nightRed } + if strength < 0.75 { return .orange } + if strength < 1.0 { return .nightPurpleSoft } + return .nightGreen + } + + var canSubmit: Bool { + password.count >= 8 && password == confirmPassword && !isLoading + } + + var body: some View { + NavigationStack { + ZStack { + EtherealBackground().ignoresSafeArea() + + if success { + VStack(spacing: 24) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 64)) + .foregroundColor(.nightGreen) + .transition(.scale.combined(with: .opacity)) + + VStack(spacing: 8) { + Text("Passwort geändert") + .font(.nightTitle(24)) + .foregroundColor(.nightPrimary) + + Text("Dein Zugang wurde erfolgreich aktualisiert.") + .font(.nightBody(16)) + .foregroundColor(.nightSecondary) + .multilineTextAlignment(.center) + } + + Button("Zum System") { + dismiss() + } + .buttonStyle(NightlyPrimaryButton()) + .padding(.top, 10) + } + .padding(40) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous)) + .padding(24) + .onAppear { Haptics.success() } + } else { + ScrollView { + VStack(alignment: .leading, spacing: 32) { + VStack(alignment: .leading, spacing: 12) { + Text("Neues Passwort") + .font(.nightTitle(32)) + .foregroundColor(.nightPrimary) + + Text("Gib ein neues, sicheres Passwort für deinen Account ein.") + .font(.nightBody(16)) + .foregroundColor(.nightSecondary) + } + .padding(.top, 20) + + VStack(spacing: 16) { + // Password Field + ZStack(alignment: .trailing) { + if showPassword { + NightlyField(text: $password, placeholder: "Neues Passwort", icon: "lock.open") + } else { + NightlyField(text: $password, placeholder: "Neues Passwort", icon: "lock", isSecure: true) + } + + Button { + showPassword.toggle() + Haptics.soft() + } label: { + Image(systemName: showPassword ? "eye.slash" : "eye") + .foregroundColor(.nightSecondary) + .padding(.trailing, 16) + } + } + + // Strength Meter + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Sicherheit") + .font(.nightLabel(12)) + .foregroundColor(.nightSecondary) + Spacer() + Text("\(Int(strength * 100))%") + .font(.nightLabel(12, weight: .bold)) + .foregroundColor(strengthColor) + } + + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.white.opacity(0.05)) + .frame(height: 6) + + Capsule().fill(strengthColor) + .frame(width: geo.size.width * strength, height: 6) + } + } + .frame(height: 6) + } + .padding(.horizontal, 4) + + NightlyField(text: $confirmPassword, placeholder: "Passwort bestätigen", icon: "lock.rotation", isSecure: true) + } + + // Rules + VStack(alignment: .leading, spacing: 10) { + RuleRow(text: "Mindestens 8 Zeichen", met: password.count >= 8) + RuleRow(text: "Großbuchstabe & Zahl", met: password.range(of: "[A-Z]", options: .regularExpression) != nil && password.range(of: "[0-9]", options: .regularExpression) != nil) + RuleRow(text: "Passwörter stimmen überein", met: !password.isEmpty && password == confirmPassword) + } + .padding(.leading, 4) + + if let err = errorMessage { + Text(err) + .font(.nightLabel(13)) + .foregroundColor(.nightRed) + .padding(.horizontal, 4) + } + + Button { + Task { await submit() } + } label: { + if isLoading { + ProgressView().tint(.nightBase) + } else { + Text("Aktualisieren") + } + } + .buttonStyle(NightlyPrimaryButton()) + .disabled(!canSubmit) + + Spacer(minLength: 100) + } + .padding(28) + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + if !success { + Button("Abbrechen") { dismiss() } + .font(.nightLabel(15)) + .foregroundColor(.nightSecondary) + } + } + } + } + } + + func submit() async { + isLoading = true + errorMessage = nil + Haptics.medium() + + do { + try await appState.updatePassword(newPassword: password) + withAnimation(.spring) { success = true } + } catch { + errorMessage = error.localizedDescription + Haptics.error() + } + isLoading = false + } +} + +struct RuleRow: View { + let text: String + let met: Bool + + var body: some View { + HStack(spacing: 10) { + Image(systemName: met ? "checkmark.circle.fill" : "circle") + .foregroundColor(met ? .nightGreen : .nightTertiary) + .font(.system(size: 14)) + + Text(text) + .font(.nightBody(15)) + .foregroundColor(met ? .nightSecondary : .nightTertiary) + } + } +} diff --git a/ios/thoughts/thoughts/Views/Components/AvatarViews.swift b/ios/thoughts/thoughts/Views/Components/AvatarViews.swift new file mode 100644 index 0000000..5d2a2a3 --- /dev/null +++ b/ios/thoughts/thoughts/Views/Components/AvatarViews.swift @@ -0,0 +1,40 @@ +import SwiftUI + +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()) + .overlay(Circle().stroke(Color.white.opacity(0.1), lineWidth: 0.5)) + } + var initials: some View { + ZStack { + Color.nightRaised + Text(String(user.displayName.prefix(1)).uppercased()) + .font(.system(size: size * 0.4, weight: .bold, design: .rounded)) + .foregroundColor(.nightPurpleSoft) + } + } +} + +struct AnonymousAvatar: View { + let size: CGFloat + var body: some View { + ZStack { + Color.nightRaised + Image(systemName: "person.fill.questionmark") + .font(.system(size: size * 0.45)) + .foregroundColor(.nightSecondary) + } + .frame(width: size, height: size) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white.opacity(0.1), lineWidth: 0.5)) + } +} diff --git a/ios/thoughts/thoughts/Views/Components/EtherealBackground.swift b/ios/thoughts/thoughts/Views/Components/EtherealBackground.swift new file mode 100644 index 0000000..4e6f2ea --- /dev/null +++ b/ios/thoughts/thoughts/Views/Components/EtherealBackground.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct EtherealBackground: View { + @State private var animate = false + + var body: some View { + GeometryReader { geo in + ZStack { + Color.nightBase.ignoresSafeArea() + + // Generative Blobs + ZStack { + // Purple Flow + BlobShape() + .fill(Color.nightPurple.opacity(0.15)) + .frame(width: 500, height: 400) + .offset(x: animate ? 50 : -50, y: animate ? -100 : 100) + .blur(radius: 80) + + // Teal Flow + BlobShape() + .fill(Color.nightGreen.opacity(0.1)) + .frame(width: 400, height: 600) + .offset(x: animate ? -80 : 80, y: animate ? 100 : -100) + .blur(radius: 90) + } + .scaleEffect(animate ? 1.2 : 0.9) + .rotationEffect(.degrees(animate ? 45 : 0)) + .animation(.easeInOut(duration: 20).repeatForever(autoreverses: true), value: animate) + + // UNIQUE FEATURE: Film Grain Overlay + GrainOverlay() + .opacity(0.03) + } + .frame(width: geo.size.width, height: geo.size.height) + .clipped() + } + .onAppear { animate = true } + } +} + +// Eine organischere Form als ein simpler Kreis +struct BlobShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let width = rect.width + let height = rect.height + + // Erzeugt eine leicht asymmetrische, abgerundete Form + path.addRoundedRect( + in: rect, + cornerSize: CGSize(width: width * 0.4, height: height * 0.4), + style: .continuous + ) + return path + } +} + +// Subtiler Rauscheffekt +struct GrainOverlay: View { + @State private var grainPositions: [(CGFloat, CGFloat)] = [] + + var body: some View { + Rectangle() + .fill(.white.opacity(0.02)) + .overlay( + Canvas { context, size in + for (x, y) in grainPositions { + context.fill(Path(CGRect(x: x, y: y, width: 0.8, height: 0.8)), with: .color(.white.opacity(0.4))) + } + } + ) + .ignoresSafeArea() + .drawingGroup() + .onAppear { + var positions: [(CGFloat, CGFloat)] = [] + for _ in 0..<500 { + positions.append((CGFloat.random(in: 0...1000), CGFloat.random(in: 0...2000))) + } + grainPositions = positions + } + } +} diff --git a/ios/thoughts/thoughts/Views/Components/FluidBackgroundView.swift b/ios/thoughts/thoughts/Views/Components/FluidBackgroundView.swift new file mode 100644 index 0000000..c1d0315 --- /dev/null +++ b/ios/thoughts/thoughts/Views/Components/FluidBackgroundView.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct FluidBackgroundView: View { + @State private var animate = false + + var body: some View { + ZStack { + Color.nightBase.ignoresSafeArea() + + // Subtile, animierte Farbkleckse im Hintergrund + ZStack { + Circle() + .fill(Color.nightPurple.opacity(0.15)) + .frame(width: 400, height: 400) + .offset(x: animate ? 100 : -100, y: animate ? -200 : -100) + + Circle() + .fill(Color.nightGreen.opacity(0.1)) + .frame(width: 350, height: 350) + .offset(x: animate ? -120 : 80, y: animate ? 150 : 50) + } + .blur(radius: 100) + .animation(.easeInOut(duration: 10).repeatForever(autoreverses: true), value: animate) + } + .onAppear { animate = true } + } +} + +// MARK: - Glass Modifier + +struct GlassModifier: ViewModifier { + var opacity: Double = 0.6 + + func body(content: Content) -> some View { + content + .background(.ultraThinMaterial.opacity(opacity)) + .background(Color.nightSurface.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(Color.white.opacity(0.1), lineWidth: 0.5) + ) + .shadow(color: .black.opacity(0.2), radius: 10, y: 5) + } +} + +extension View { + func glassStyle(opacity: Double = 0.6) -> some View { + modifier(GlassModifier(opacity: opacity)) + } +} diff --git a/ios/thoughts/thoughts/Views/Components/ReportSheet.swift b/ios/thoughts/thoughts/Views/Components/ReportSheet.swift new file mode 100644 index 0000000..fd6949e --- /dev/null +++ b/ios/thoughts/thoughts/Views/Components/ReportSheet.swift @@ -0,0 +1,132 @@ +import SwiftUI + +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 + @State private var errorMessage: String? + + 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 { + EtherealBackground().ignoresSafeArea() + + if submitted { + VStack(spacing: 24) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 64)) + .foregroundColor(.nightGreen) + + VStack(spacing: 8) { + Text("Danke für deine Meldung") + .font(.nightTitle(22)) + .foregroundColor(.nightPrimary) + + Text("Wir prüfen den Inhalt so schnell wie möglich.") + .font(.nightBody(16)) + .foregroundColor(.nightSecondary) + .multilineTextAlignment(.center) + } + + Button("Schließen") { dismiss() } + .font(.nightLabel(16, weight: .bold)) + .foregroundColor(.nightPurpleSoft) + .padding(.top, 8) + } + .padding(40) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + Text("Warum möchtest du das melden?") + .font(.nightTitle(24)) + .foregroundColor(.nightPrimary) + .padding(.top, 20) + + VStack(spacing: 0) { + ForEach(ReportReason.allCases, id: \.self) { reason in + Button { + selected = reason + Haptics.soft() + } label: { + HStack { + Text(reason.rawValue) + .font(.nightBody(17)) + .foregroundColor(.nightPrimary) + Spacer() + if selected == reason { + Image(systemName: "checkmark") + .foregroundColor(.nightPurpleSoft) + .font(.system(size: 14, weight: .bold)) + } + } + .padding(20) + .background(selected == reason ? Color.white.opacity(0.05) : Color.clear) + } + if reason != ReportReason.allCases.last { + Divider().background(Color.nightBorder).padding(.horizontal, 20) + } + } + } + .background(RoundedRectangle(cornerRadius: 20).fill(.ultraThinMaterial.opacity(0.3))) + .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.white.opacity(0.1), lineWidth: 0.5)) + + Button { + guard let reason = selected else { return } + Task { await submit(reason: reason) } + } label: { + if isLoading { + ProgressView().tint(.nightBase) + } else { + Text("Melden") + } + } + .buttonStyle(NightlyPrimaryButton()) + .disabled(selected == nil || isLoading) + .padding(.top, 8) + + if let err = errorMessage { + Text(err) + .font(.nightLabel(13)) + .foregroundColor(.nightRed) + .padding(.top, 4) + } + } + .padding(24) + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Abbrechen") { dismiss() } + .foregroundColor(.nightSecondary) + } + } + } + .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 + Haptics.success() + } catch { + errorMessage = "Meldung konnte nicht gesendet werden. Bitte versuche es später erneut." + Haptics.error() + } + } +} diff --git a/ios/thoughts/thoughts/Views/Components/ResonateButton.swift b/ios/thoughts/thoughts/Views/Components/ResonateButton.swift new file mode 100644 index 0000000..06b9dce --- /dev/null +++ b/ios/thoughts/thoughts/Views/Components/ResonateButton.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct ResonanceButton: View { + let count: Int + let isActive: Bool + let action: () -> Void + @State private var scale: CGFloat = 1.0 + + var body: some View { + Button { + Haptics.light() + withAnimation(.spring(duration: 0.3, bounce: 0.6)) { scale = 1.2 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.spring(duration: 0.2)) { scale = 1.0 } + } + action() + } label: { + HStack(spacing: 6) { + Image(systemName: isActive ? "heart.fill" : "heart") + .font(.system(size: 16)) + .foregroundColor(isActive ? .nightRed : .nightSecondary) + .scaleEffect(scale) + + if count > 0 { + Text("\(count)") + .font(.nightLabel(14, weight: .medium)) + .foregroundColor(isActive ? .nightRed : .nightSecondary) + } + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background( + Capsule() + .fill(isActive ? Color.nightRed.opacity(0.1) : Color.white.opacity(0.05)) + ) + } + } +} diff --git a/ios/thoughts/thoughts/Views/Components/TouchDesignerBackground.swift b/ios/thoughts/thoughts/Views/Components/TouchDesignerBackground.swift new file mode 100644 index 0000000..90760ad --- /dev/null +++ b/ios/thoughts/thoughts/Views/Components/TouchDesignerBackground.swift @@ -0,0 +1,54 @@ +import SwiftUI + +struct TouchDesignerBackground: View { + @State private var start = Date() + @Environment(\.scenePhase) private var scenePhase + + var body: some View { + TimelineView(.animation(paused: scenePhase != .active)) { timeline in + Canvas { context, size in + let now = timeline.date.timeIntervalSince(start) + + // 1. Deeper Black Base + context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(Color(hex: "020205"))) + + // 2. Subtile Grid Lines + let spacing: CGFloat = 40 + var gridPath = Path() + for x in stride(from: 0, to: size.width, by: spacing) { + gridPath.move(to: CGPoint(x: x, y: 0)) + gridPath.addLine(to: CGPoint(x: x, y: size.height)) + } + for y in stride(from: 0, to: size.height, by: spacing) { + gridPath.move(to: CGPoint(x: 0, y: y)) + gridPath.addLine(to: CGPoint(x: size.width, y: y)) + } + context.stroke(gridPath, with: .color(Color.nightPurple.opacity(0.03)), lineWidth: 0.5) + + // 3. Generative Data Streams (Flowing Lines) + for i in 0..<10 { + let speed = 40.0 + Double(i * 10) + let offset = (now * speed).truncatingRemainder(dividingBy: size.height + size.width) + var linePath = Path() + linePath.move(to: CGPoint(x: 0, y: offset)) + linePath.addLine(to: CGPoint(x: offset, y: 0)) + context.stroke(linePath, with: .color(Color.nightPurple.opacity(0.05)), lineWidth: 0.5) + } + + // 4. Generative Energy Beams + for i in 0..<4 { + let phase = now * 0.2 + Double(i) * .pi / 2 + let x = size.width * (0.5 + 0.3 * cos(phase)) + let y = size.height * (0.5 + 0.3 * sin(phase * 1.3)) + + let rect = CGRect(x: x - 150, y: y - 150, width: 300, height: 300) + context.addFilter(.blur(radius: 80)) + context.fill(Circle().path(in: rect), with: .color( + i % 2 == 0 ? Color.nightPurple.opacity(0.08) : Color.nightGreen.opacity(0.06) + )) + } + } + } + .ignoresSafeArea() + } +} diff --git a/ios/thoughts/thoughts/Views/ComposeView.swift b/ios/thoughts/thoughts/Views/ComposeView.swift index d258c65..fbe13da 100644 --- a/ios/thoughts/thoughts/Views/ComposeView.swift +++ b/ios/thoughts/thoughts/Views/ComposeView.swift @@ -10,162 +10,48 @@ struct ComposeView: View { @State private var isAnonymous = false @State private var isPosting = false @State private var errorMessage: String? + @State private var showContentWarning = false + @State private var contentWarningType: ContentWarningType? = nil + @State private var showSleepReminder = false + @FocusState private var isFocused: Bool - private let maxChars = 280 + private let maxChars = 420 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) + + Color.clear + .contentShape(Rectangle()) + .onTapGesture { isFocused = false } 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) + VStack(alignment: .leading, spacing: 24) { + headerSection + .padding(.top, 20) - // 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) + textInputSection - // 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)) - } - } - } + moodPickerSection + .padding(.top, 10) - // Mood picker inline - MoodPickerRow(selected: $selectedMood) - .padding(.top, 4) - - Spacer().frame(height: 20) + if let err = errorMessage { + Text(err) + .font(.nightLabel(13)) + .foregroundColor(.nightRed) + .padding(.top, 10) } } - .padding(.horizontal, 16) - .padding(.top, 18) + .padding(24) } Spacer() - // Bottom bar: anonymous toggle + countdown - Divider().background(Color.nightBorder) - - HStack(spacing: 14) { - // Anonymous toggle - Button { - Haptics.select() - withAnimation(.spring(duration: 0.3, bounce: 0.4)) { - isAnonymous.toggle() - } - } label: { - HStack(spacing: 6) { - Image(systemName: isAnonymous ? "theatermasks.fill" : "theatermasks") - .font(.system(size: 15)) - Text(isAnonymous ? "anonym" : "anonym posten") - .font(.nightLabel(13)) - } - .foregroundColor(isAnonymous ? .nightPrimary : .nightSecondary) - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background(isAnonymous ? Color.nightRaised : .clear) - .clipShape(Capsule()) - .overlay( - Capsule() - .strokeBorder( - isAnonymous ? Color.nightBorder : .clear, - lineWidth: 1 - ) - ) - } - - Spacer() - - WindowCountdownView() - } - .padding(.horizontal, 20) - .padding(.vertical, 10) - .padding(.bottom, 8) - - if let err = errorMessage { - Text(err) - .font(.nightLabel(13)) - .foregroundColor(.nightRed) - .padding(.horizontal, 20) - .padding(.bottom, 8) - } + bottomBar } } .navigationBarTitleDisplayMode(.inline) @@ -173,21 +59,203 @@ struct ComposeView: View { ToolbarItem(placement: .navigationBarLeading) { Button("Abbrechen") { dismiss() } .foregroundColor(.nightSecondary) - .font(.nightBody(16)) - } - ToolbarItem(placement: .navigationBarTrailing) { - PostButton(canPost: canPost, isPosting: isPosting) { - Task { await submit() } - } + .font(.nightLabel(15)) } } + .alert("Zeit fuer Schlaf?", isPresented: $showSleepReminder) { + Button("Trotzdem posten") { Task { await submit() } } + Button("Gute Nacht", role: .cancel) { dismiss() } + } message: { + Text("Du bist schon lange wach. Dein Gedanke ist wichtig — aber Schlaf ist es auch. Willst du trotzdem posten?") + } } .preferredColorScheme(.dark) } - var currentTime: String { - let f = DateFormatter(); f.dateFormat = "HH:mm" - return f.string(from: Date()) + // MARK: - Mood Background + + @ViewBuilder + var moodBackground: some View { + if let mood = selectedMood { + ZStack { + Color.nightBase + LinearGradient( + colors: mood.colorGradient.map { $0.opacity(0.08) }, + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .animation(.easeInOut(duration: 0.8), value: selectedMood) + } + } else { + EtherealBackground() + } + } + + // MARK: - Sections + + var headerSection: some View { + HStack(spacing: 12) { + if isAnonymous { + AnonymousAvatar(size: 40) + } else if let user = appState.currentUser { + AvatarView(user: user, size: 40) + } + + VStack(alignment: .leading, spacing: 2) { + Text(isAnonymous ? "Anonym posten" : (appState.currentUser?.displayName ?? "User")) + .font(.nightLabel(16, weight: .semibold)) + .foregroundColor(.nightPrimary) + + if !AppState.isInNightWindow(at: Date(), bypass: AppSettings.shared.bypassWindowLogic) { + HStack(spacing: 4) { + Image(systemName: "moon.zzz") + .font(.system(size: 10)) + Text("Außerhalb des Nachtfensters") + .font(.nightLabel(11)) + } + .foregroundColor(.nightTertiary) + } else if let mood = selectedMood { + Text(mood.description) + .font(.nightLabel(12)) + .foregroundColor(mood.color) + } else { + Text("Wähle deine Stimmung") + .font(.nightLabel(12)) + .foregroundColor(.nightSecondary) + } + } + } + } + + var textInputSection: some View { + VStack(alignment: .leading, spacing: 8) { + TextField("Was beschäftigt dich heute Nacht?", text: $text, axis: .vertical) + .font(.nightBody(20)) + .foregroundColor(.nightPrimary) + .focused($isFocused) + .lineLimit(5...15) + .tint(selectedMood?.color ?? .nightPurpleSoft) + .onChange(of: text) { _, new in + if new.count > maxChars { text = String(new.prefix(maxChars)) } + } + + HStack { + Text("\(remaining)") + .font(.nightLabel(11)) + .foregroundColor(remaining < 50 ? .nightRed : .nightTertiary) + Spacer() + contentWarningButton + } + } + } + + var contentWarningButton: some View { + Button { + showContentWarning.toggle() + } label: { + HStack(spacing: 4) { + Image(systemName: contentWarningType == nil ? "exclamationmark.shield" : "exclamationmark.shield.fill") + .font(.system(size: 12)) + if let cw = contentWarningType { + Text(cw.shortLabel) + .font(.nightLabel(10)) + } + } + .foregroundColor(contentWarningType == nil ? .nightTertiary : .nightRed) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule().fill(contentWarningType == nil ? Color.white.opacity(0.05) : Color.nightRed.opacity(0.1))) + } + } + + var moodPickerSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Wie fühlst du dich?") + .font(.nightLabel(14, weight: .medium)) + .foregroundColor(.nightSecondary) + + MoodPickerExpanded(selected: $selectedMood) + + if showContentWarning { + contentWarningPicker + } + } + } + + var contentWarningPicker: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Inhaltswarnung") + .font(.nightLabel(12, weight: .semibold)) + .foregroundColor(.nightRed) + + ForEach(ContentWarningType.allCases, id: \.self) { type in + Button { + contentWarningType = contentWarningType == type ? nil : type + Haptics.light() + } label: { + HStack(spacing: 8) { + Image(systemName: type.icon) + .font(.system(size: 12)) + .foregroundColor(contentWarningType == type ? .nightRed : .nightTertiary) + Text(type.label) + .font(.nightLabel(13)) + .foregroundColor(.nightPrimary) + Spacer() + if contentWarningType == type { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(.nightRed) + } + } + .padding(.vertical, 8) + } + } + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.nightRed.opacity(0.05))) + } + + var bottomBar: some View { + VStack(spacing: 20) { + HStack { + Toggle(isOn: $isAnonymous) { + Label("Identität verbergen", systemImage: "eye.slash") + .font(.nightLabel(14)) + .foregroundColor(.nightSecondary) + } + .tint(.nightPurple) + + Spacer() + + WindowCountdownView() + } + .padding(.horizontal, 24) + + Button { + Task { await submit() } + } label: { + if isPosting { + ProgressView().tint(.white) + } else { + Text("Veröffentlichen") + .font(.nightLabel(16, weight: .bold)) + } + } + .buttonStyle(NightlyPrimaryButton()) + .disabled(!canPost || isPosting) + .padding(.horizontal, 24) + .padding(.bottom, 34) + } + .background( + Rectangle() + .fill(.ultraThinMaterial) + .ignoresSafeArea() + ) + } + + private func hoursSinceLastPost() -> TimeInterval { + guard let lastPost = UserDefaults.standard.object(forKey: "lastPostDate") as? Date else { return 0 } + return Date().timeIntervalSince(lastPost) / 3600 } func submit() async { @@ -196,10 +264,12 @@ struct ComposeView: View { errorMessage = nil defer { isPosting = false } do { - try await supabase.createPost( + _ = try await supabase.createPost( content: text.trimmingCharacters(in: .whitespacesAndNewlines), mood: mood, - isAnonymous: isAnonymous + isAnonymous: isAnonymous, + hasContentWarning: contentWarningType != nil, + contentWarningType: contentWarningType ) Haptics.success() appState.markAsPosted() @@ -211,23 +281,18 @@ struct ComposeView: View { } } -// MARK: - Mood Picker +// MARK: - Mood Picker (Expanded) -struct MoodPickerRow: View { +struct MoodPickerExpanded: 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) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { ForEach(Mood.allCases, id: \.self) { mood in - MoodChip(mood: mood, isSelected: selected == mood) { + MoodCard(mood: mood, isSelected: selected == mood) { Haptics.soft() - withAnimation(.spring(duration: 0.3, bounce: 0.3)) { + withAnimation(.spring(duration: 0.4, bounce: 0.4)) { selected = selected == mood ? nil : mood } } @@ -237,89 +302,91 @@ struct MoodPickerRow: View { } } -struct MoodChip: View { +struct MoodCard: View { let mood: Mood let isSelected: Bool let action: () -> Void var body: some View { Button(action: action) { - HStack(spacing: 5) { + VStack(spacing: 6) { 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) + .font(.system(size: isSelected ? 22 : 18)) + .animation(.spring(duration: 0.3), value: isSelected) + + VStack(spacing: 2) { + Text(mood.label) + .font(.nightLabel(13, weight: isSelected ? .semibold : .medium)) + Text(mood.description) + .font(.nightLabel(9)) + .foregroundColor(.nightTertiary) + .lineLimit(1) + } } - .padding(.horizontal, 11) - .padding(.vertical, 7) + .padding(.horizontal, 14) + .padding(.vertical, 10) .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) - } - } + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(isSelected ? mood.color.opacity(0.15) : Color.white.opacity(0.04)) ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(isSelected ? mood.color.opacity(0.4) : Color.white.opacity(0.06), lineWidth: 1) + ) + .foregroundColor(isSelected ? mood.color : .nightSecondary) } } } -// 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 +// MARK: - Countdown (Smart) struct WindowCountdownView: View { @State private var label = "" var body: some View { - HStack(spacing: 5) { - Image(systemName: "clock") - .font(.system(size: 11)) + HStack(spacing: 6) { + Image(systemName: "timer") + .font(.system(size: 12)) Text(label) - .font(.nightMono(11)) + .font(.nightLabel(12, weight: .bold)) } - .foregroundColor(.nightPurple.opacity(0.5)) + .foregroundColor(.nightTertiary) .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" + let now = Date() + let inWindow = AppState.isInNightWindow(at: now, bypass: AppSettings.shared.bypassWindowLogic) + + if !inWindow { + let nextOpen = nextWindowOpenDate(from: now) + let diff = max(0, Int(nextOpen.timeIntervalSince(now))) + let hours = diff / 3600 + let mins = (diff % 3600) / 60 + label = hours > 0 ? "Opens in \(hours)h \(mins)m" : "Opens in \(mins)m" + } else { + let close = AppState.windowCloseTime(at: now) + let diff = max(0, Int(close.timeIntervalSince(now))) + let hours = diff / 3600 + let mins = (diff % 3600) / 60 + label = hours > 0 ? "\(hours)h \(mins)m remaining" : "\(mins)m remaining" + } } -} + + private func nextWindowOpenDate(from date: Date) -> Date { + let cal = Calendar.current + let hour = cal.component(.hour, from: date) + let isWeekend = cal.isDateInWeekend(date) + + // Same-day window hasn't opened yet + if hour < 23 && !isWeekend { return cal.date(bySettingHour: 23, minute: 0, second: 0, of: date)! } + if hour < 22 && isWeekend { return cal.date(bySettingHour: 22, minute: 0, second: 0, of: date)! } + + // We're past both — next window starts tomorrow evening + let tomorrow = cal.date(byAdding: .day, value: 1, to: date)! + let tomorrowWeekend = cal.isDateInWeekend(tomorrow) + let openHour = tomorrowWeekend ? 22 : 23 + return cal.date(bySettingHour: openHour, minute: 0, second: 0, of: tomorrow)! + } +} \ No newline at end of file diff --git a/ios/thoughts/thoughts/Views/DiaryView.swift b/ios/thoughts/thoughts/Views/DiaryView.swift index 62acd0f..cc71232 100644 --- a/ios/thoughts/thoughts/Views/DiaryView.swift +++ b/ios/thoughts/thoughts/Views/DiaryView.swift @@ -1,8 +1,6 @@ import Combine import SwiftUI -/// Persönliches Tagebuch — alle eigenen Posts, auch die anonymen. -/// Bleibt für immer. Das ist der Retention-Hook. struct DiaryView: View { @StateObject private var viewModel = DiaryViewModel() @EnvironmentObject var appState: AppState @@ -11,46 +9,46 @@ struct DiaryView: View { var body: some View { NavigationStack { ZStack { - Color.nightBase.ignoresSafeArea() + EtherealBackground().ignoresSafeArea() if viewModel.groupedPosts.isEmpty && !viewModel.isLoading { DiaryEmptyView() } else { ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - // "Vor genau einem Jahr" — Memory + LazyVStack(alignment: .leading, spacing: 20) { if let memory = viewModel.yearAgoPost { MemoryBanner(post: memory) - .padding(.bottom, 4) + .padding(.horizontal, 16) + .padding(.top, 16) } ForEach(Array(viewModel.groupedPosts.enumerated()), id: \.element.nightLabel) { groupIndex, group in - // Nacht-Header - DiaryNightHeader(label: group.nightLabel, count: group.posts.count) - .opacity(postsAppeared ? 1 : 0) - .animation( - .spring(duration: 0.35, bounce: 0.1) - .delay(Double(min(groupIndex, 6)) * 0.06), - value: postsAppeared - ) + VStack(alignment: .leading, spacing: 10) { + Text(group.nightLabel.uppercased()) + .font(.nightLabel(11, weight: .bold)) + .foregroundColor(.nightPurpleSoft) + .tracking(1) + .padding(.horizontal, 20) - ForEach(group.posts) { post in - DiaryPostRow(post: post) { - Task { await viewModel.deletePost(post) } + ForEach(group.posts) { post in + DiaryPostRow(post: post) { + Task { await viewModel.deletePost(post) } + } + .padding(.horizontal, 16) + .opacity(postsAppeared ? 1 : 0) + .offset(y: postsAppeared ? 0 : 8) + .animation( + .spring(duration: 0.4, bounce: 0.15) + .delay(Double(min(groupIndex, 6)) * 0.06), + value: postsAppeared + ) } - .opacity(postsAppeared ? 1 : 0) - .offset(y: postsAppeared ? 0 : 12) - .animation( - .spring(duration: 0.35, bounce: 0.1) - .delay(Double(min(groupIndex, 6)) * 0.06 + 0.03), - value: postsAppeared - ) - Divider().background(Color.nightBorder).padding(.leading, 16) } } .onAppear { postsAppeared = true } - Color.clear.frame(height: 100) + Color.clear.frame(height: 120) } + .padding(.top, 8) } .refreshable { await viewModel.load() } } @@ -58,13 +56,9 @@ struct DiaryView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { - HStack(spacing: 7) { - Image(systemName: "book.closed.fill") - .foregroundColor(.nightPurple) - Text("tagebuch") - .font(.nightTitle(17)) - .foregroundColor(.nightPrimary) - } + Text("Tagebuch") + .font(.nightTitle(20)) + .foregroundColor(.nightPrimary) } } } @@ -72,23 +66,20 @@ struct DiaryView: View { } } -// 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) { + VStack(alignment: .leading, spacing: 14) { HStack(spacing: 8) { - Image(systemName: "clock.arrow.circlepath") + Image(systemName: "sparkles") + .font(.system(size: 13)) .foregroundColor(.nightPurpleSoft) - .font(.system(size: 14)) - Text("vor genau einem jahr") - .font(.nightLabel(12, weight: .semibold)) - .foregroundColor(.nightPurpleSoft) - .kerning(0.5) + Text("Genau vor einem Jahr") + .font(.nightLabel(13, weight: .bold)) + .foregroundColor(.nightPrimary) Spacer() Button { withAnimation { show = false } } label: { Image(systemName: "xmark") @@ -99,163 +90,170 @@ struct MemoryBanner: View { Text(post.content) .font(.nightBody(15)) - .foregroundColor(.nightPrimary.opacity(0.85)) - .lineSpacing(4) + .foregroundColor(.nightPrimary.opacity(0.88)) + .lineSpacing(5) + .multilineTextAlignment(.leading) - HStack(spacing: 5) { + HStack { if let mood = post.mood { - Text(mood.emoji).font(.nightMono(11)) - Text(mood.label).font(.nightLabel(11)) + HStack(spacing: 4) { + Circle().fill(mood.color).frame(width: 5, height: 5) + Text(mood.label) + .font(.nightLabel(10, weight: .bold)) + .foregroundColor(mood.color.opacity(0.8)) + } } - Text("·") + Spacer() Text(post.formattedTime) - .font(.nightMono(11)) + .font(.nightLabel(10)) + .foregroundColor(.nightTertiary) } - .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 - ) - ) - } - ) + .padding(18) + .background(.ultraThinMaterial.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) .overlay( - Rectangle().fill(Color.nightBorder).frame(height: 1), - alignment: .bottom + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(LinearGradient(colors: [.nightPurpleSoft.opacity(0.25), .clear], startPoint: .topLeading, endPoint: .bottomTrailing), lineWidth: 0.5) ) } } } -// 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 + @State private var isContentRevealed = 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") - } + HStack(spacing: 0) { + moodStripe + mainContent + } + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .contextMenu { + Button(role: .destructive) { + confirmDelete = true + } label: { + Label("Löschen", systemImage: "trash") } } - .confirmationDialog( - "Post löschen?", - isPresented: $confirmDelete, - titleVisibility: .visible - ) { + .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.") + Text("Der Post wird aus dem Feed entfernt, bleibt aber in deinem Archiv.") } } + + var moodStripe: some View { + Group { + if let mood = post.mood { + LinearGradient( + colors: mood.colorGradient.map { $0.opacity(0.5) }, + startPoint: .top, endPoint: .bottom + ) + .frame(width: 2.5) + } else { + Color.nightTertiary.opacity(0.1) + .frame(width: 1) + } + } + } + + var mainContent: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(post.formattedTime) + .font(.nightLabel(12, weight: .medium)) + .foregroundColor(.nightSecondary) + + if post.isAnonymous { + Text("· Anonym") + .font(.nightLabel(11)) + .foregroundColor(.nightTertiary) + } + + if post.hasContentWarning { + Image(systemName: "exclamationmark.shield.fill") + .font(.system(size: 9)) + .foregroundColor(.nightRed.opacity(0.5)) + } + + Spacer() + + if post.resonanceCount > 0 { + HStack(spacing: 3) { + Image(systemName: "heart.fill") + .font(.system(size: 9)) + Text("\(post.resonanceCount)") + .font(.nightLabel(11, weight: .bold)) + } + .foregroundColor(.nightRed.opacity(0.7)) + } + } + + if post.hasContentWarning && !isContentRevealed { + Button { + withAnimation { isContentRevealed = true } + } label: { + HStack(spacing: 4) { + Image(systemName: post.contentWarningType?.icon ?? "exclamationmark.triangle") + .font(.system(size: 10)) + Text(post.contentWarningType?.shortLabel ?? "Achtung") + .font(.nightLabel(11)) + Text("— Tippe zum Anzeigen") + .font(.nightLabel(10)) + .foregroundColor(.nightTertiary) + } + .foregroundColor(.nightRed.opacity(0.6)) + } + } else { + Text(post.content) + .font(.nightBody(15)) + .foregroundColor(.nightPrimary.opacity(0.88)) + .lineSpacing(4) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + + if let mood = post.mood { + HStack(spacing: 4) { + Circle().fill(mood.color).frame(width: 5, height: 5) + Text(mood.label) + .font(.nightLabel(10, weight: .bold)) + .foregroundColor(mood.color.opacity(0.75)) + } + } + } + .padding(16) + .background(.ultraThinMaterial.opacity(0.25)) + } } -// 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) + VStack(spacing: 24) { + Image(systemName: "moon.stars") + .font(.system(size: 52)) + .foregroundColor(.nightPurple.opacity(0.25)) + + VStack(spacing: 8) { + Text("Noch keine Erinnerungen") + .font(.nightTitle(20)) + .foregroundColor(.nightPrimary) + + Text("Deine Gedanken landen hier\nund bleiben für immer sicher verwahrt.") + .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] } @@ -271,27 +269,19 @@ class DiaryViewModel: ObservableObject { 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 = Dictionary(grouping: posts) { post -> String in + post.formattedNightDate } + .sorted { $0.key > $1.key } + .map { NightGroup(nightLabel: $0.key, posts: $0.value) } - 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)] + groupedPosts = [NightGroup(nightLabel: "Gestern", posts: Post.previews)] #endif } } @@ -300,4 +290,4 @@ class DiaryViewModel: ObservableObject { try? await supabase.softDeletePost(id: post.id) await load() } -} +} \ No newline at end of file diff --git a/ios/thoughts/thoughts/Views/FeedView.swift b/ios/thoughts/thoughts/Views/FeedView.swift index c840f4b..46dd73d 100644 --- a/ios/thoughts/thoughts/Views/FeedView.swift +++ b/ios/thoughts/thoughts/Views/FeedView.swift @@ -1,31 +1,24 @@ import SwiftUI -// MARK: - Feed - struct FeedView: View { @StateObject private var viewModel = FeedViewModel() @EnvironmentObject var appState: AppState var realtime: RealtimeService? = nil @State private var postsAppeared = false - // Gerade Jetzt = posts younger than 10 minutes var rightNowPosts: [Post] { viewModel.posts.filter { $0.isRightNow } } - var nightPosts: [Post] { viewModel.posts } + var nightPosts: [Post] { viewModel.posts.filter { !$0.isExpired } } var body: some View { NavigationStack { ZStack { - Color.nightBase.ignoresSafeArea() + Color.clear.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 + LazyVStack(spacing: 16) { if !rightNowPosts.isEmpty && appState.windowState == .posted { RightNowSection( posts: rightNowPosts, @@ -33,42 +26,40 @@ struct FeedView: View { Task { await viewModel.resonate(post) } } ) - .padding(.bottom, 2) + .padding(.horizontal, 16) } - // ── TRENNLINIE MIT KONTEXT ────────────────────── NightContextBar( windowState: appState.windowState, totalCount: nightPosts.count, liveCount: rightNowPosts.count ) + .padding(.horizontal, 20) - // ── HEUTE NACHT ──────────────────────────────── - // Alle Posts dieser Nacht, chronologisch - ForEach(Array(nightPosts.enumerated()), id: \.element.id) { index, post in + ForEach(nightPosts) { post in PostRowView(post: post) { Task { await viewModel.resonate(post) } } - .opacity(postsAppeared ? 1 : 0) - .offset(y: postsAppeared ? 0 : 14) - .animation( - .spring(duration: 0.35, bounce: 0.1) - .delay(Double(min(index, 10)) * 0.04), - value: postsAppeared - ) - Divider() - .background(Color.nightBorder) - .padding(.leading, 16) + .padding(.horizontal, 16) + .transition(.opacity.combined(with: .move(edge: .bottom))) } - .onAppear { postsAppeared = true } if viewModel.isLoading { - ForEach(0..<5, id: \.self) { _ in + ForEach(0..<4, id: \.self) { _ in SkeletonPostRow() - Divider().background(Color.nightBorder).padding(.leading, 16) + .padding(.horizontal, 16) + .padding(.vertical, 6) } } + if let error = viewModel.errorMessage { + Text(error) + .font(.nightLabel(13)) + .foregroundColor(.nightRed) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + Color.clear.frame(height: 100) } } @@ -93,13 +84,11 @@ struct FeedView: View { } } -// MARK: - Wordmark - struct NightlyWordmark: View { var body: some View { - HStack(spacing: 7) { + HStack(spacing: 6) { Text("◐") - .font(.system(size: 15)) + .font(.system(size: 14)) .foregroundColor(.nightPurple) Text("nightly") .font(.system(size: 17, weight: .bold, design: .rounded)) @@ -108,19 +97,6 @@ struct NightlyWordmark: View { } } -// 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 @@ -129,68 +105,55 @@ struct RightNowSection: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - // Header - HStack(spacing: 10) { - // Pulsierender grüner Punkt = LIVE + HStack(spacing: 8) { ZStack { Circle() - .fill(Color.nightGreen.opacity(0.25)) - .frame(width: 16, height: 16) - .scaleEffect(pulse ? 1.8 : 1.0) + .fill(Color.nightGreen.opacity(0.3)) + .frame(width: 14, height: 14) + .scaleEffect(pulse ? 2.0 : 1.0) .opacity(pulse ? 0 : 1) Circle() .fill(Color.nightGreen) - .frame(width: 7, height: 7) + .frame(width: 6, height: 6) } .onAppear { - withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: false)) { - pulse = true - } + withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: false)) { pulse = true } } Text("gerade jetzt") - .font(.nightLabel(12, weight: .semibold)) + .font(.nightLabel(11, weight: .semibold)) .foregroundColor(.nightGreen) .kerning(0.8) Text("· \(posts.count) \(posts.count == 1 ? "Person" : "Personen") gleichzeitig wach") - .font(.nightLabel(12)) + .font(.nightLabel(11)) .foregroundColor(.nightSecondary) Spacer() - // Info-Tooltip - HelpTooltip( - text: "Leute die in den letzten 10 Minuten gepostet haben — ihr seid buchstäblich gleichzeitig wach." - ) + 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) + .padding(.horizontal, 14) + .padding(.top, 14) + .padding(.bottom, 10) - // 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) + .padding(.horizontal, 14) + .padding(.bottom, 14) } } - .background( - ZStack { - Color.nightSurface - Color.nightGreen.opacity(0.025) - } - ) - .overlay( + .background(Color.nightSurface) + .overlay(alignment: .bottom) { Rectangle() - .fill(Color.nightGreen.opacity(0.15)) - .frame(height: 1), - alignment: .bottom - ) + .fill(Color.nightGreen.opacity(0.1)) + .frame(height: 0.5) + } + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } @@ -199,72 +162,54 @@ struct RightNowCard: View { let onResonate: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { if post.isAnonymous { - AnonymousAvatar(size: 26) + AnonymousAvatar(size: 22) } else { - AvatarView(user: post.author, size: 26) + AvatarView(user: post.author, size: 22) } Text(post.isAnonymous ? "anonym" : "@\(post.author.username)") - .font(.nightLabel(12)) - .foregroundColor(post.isAnonymous ? .nightSecondary : .nightPrimary) + .font(.nightLabel(10, weight: .medium)) + .foregroundColor(post.isAnonymous ? .nightTertiary : .nightPrimary) .lineLimit(1) Spacer() if let mood = post.mood { Text(mood.emoji) - .font(.nightMono(11)) - .foregroundColor(mood.color.opacity(0.7)) + .font(.system(size: 10)) } } Text(post.content) - .font(.nightBody(14)) - .foregroundColor(.nightPrimary.opacity(0.88)) - .lineSpacing(4) - .lineLimit(4) + .font(.nightBody(13)) + .foregroundColor(.nightPrimary.opacity(0.85)) + .lineSpacing(3) + .lineLimit(3) + .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) Button(action: onResonate) { - HStack(spacing: 4) { + HStack(spacing: 3) { Image(systemName: post.hasResonated ? "heart.fill" : "heart") - .font(.system(size: 12)) - .foregroundColor(post.hasResonated ? .nightRed : .nightSecondary) + .font(.system(size: 11)) + .foregroundColor(post.hasResonated ? .nightRed : .nightTertiary) if post.resonanceCount > 0 { Text("\(post.resonanceCount)") - .font(.nightLabel(11)) - .foregroundColor(.nightSecondary) + .font(.nightLabel(10)) + .foregroundColor(.nightTertiary) } } } } - .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) - } - ) + .padding(12) + .frame(width: 200, height: 130) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(.ultraThinMaterial.opacity(0.3))) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Color.white.opacity(0.05), lineWidth: 0.5)) } } -// MARK: - Context Bar (der Übergang zwischen Gerade Jetzt und Heute Nacht) - struct NightContextBar: View { let windowState: AppState.WindowState let totalCount: Int @@ -272,63 +217,53 @@ struct NightContextBar: View { var body: some View { HStack { - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 6) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 5) { Text("heute nacht") - .font(.nightLabel(12, weight: .semibold)) + .font(.nightLabel(11, weight: .semibold)) .foregroundColor(.nightSecondary) - .kerning(0.5) + .kerning(0.4) if totalCount > 0 { Text("· \(totalCount) Gedanken") - .font(.nightLabel(12)) + .font(.nightLabel(11)) .foregroundColor(.nightTertiary) } } Text(statusSubtitle) - .font(.nightLabel(11)) + .font(.nightLabel(10)) .foregroundColor(.nightTertiary) } Spacer() - // Window status pill - HStack(spacing: 5) { + HStack(spacing: 4) { Circle() - .fill(windowState == .open ? Color.nightGreen : Color.nightTertiary) - .frame(width: 6, height: 6) - Text(windowState == .open ? "offen" : "geschlossen") - .font(.nightLabel(11)) + .fill(windowState == .open || windowState == .posted ? Color.nightGreen : Color.nightTertiary) + .frame(width: 5, height: 5) + Text(windowState == .open || windowState == .posted ? "offen" : "geschlossen") + .font(.nightLabel(10, weight: .medium)) .foregroundColor(.nightSecondary) } - .padding(.horizontal, 10) - .padding(.vertical, 5) + .padding(.horizontal, 8) + .padding(.vertical, 4) .background(Color.nightRaised) .clipShape(Capsule()) } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .overlay( - Rectangle() - .fill(Color.nightBorder) - .frame(height: 1), - alignment: .bottom - ) + .padding(.vertical, 10) } - var statusSubtitle: String { +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" + case .open: return "Du kannst posten" + case .posted: return "Du kannst weitersposten" + case .closed: return "Fenster öffnet später" + case .missed: return "Nächste Chance heute Nacht" } } } -// MARK: - Help Tooltip - struct HelpTooltip: View { let text: String @State private var show = false @@ -341,7 +276,7 @@ struct HelpTooltip: View { } } label: { Image(systemName: "info.circle") - .font(.system(size: 13)) + .font(.system(size: 12)) .foregroundColor(.nightTertiary) } .overlay(alignment: .topTrailing) { @@ -350,11 +285,7 @@ struct HelpTooltip: View { .font(.nightBody(12)) .foregroundColor(.nightPrimary) .padding(10) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color.nightRaised) - .shadow(color: .black.opacity(0.4), radius: 8, y: 4) - ) + .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))) @@ -364,16 +295,14 @@ struct HelpTooltip: View { } } -// MARK: - Empty State - struct EmptyNightView: View { let windowState: AppState.WindowState var body: some View { - VStack(spacing: 20) { + VStack(spacing: 24) { Text("◐") - .font(.system(size: 52)) - .foregroundColor(.nightPurple.opacity(0.4)) + .font(.system(size: 48)) + .foregroundColor(.nightPurple.opacity(0.3)) VStack(spacing: 8) { Text(windowState == .open ? "sei der erste heute nacht" : "noch ruhig hier") @@ -392,4 +321,4 @@ struct EmptyNightView: View { } .padding(40) } -} +} \ No newline at end of file diff --git a/ios/thoughts/thoughts/Views/LegalView.swift b/ios/thoughts/thoughts/Views/LegalView.swift index ad161d2..32c1577 100644 --- a/ios/thoughts/thoughts/Views/LegalView.swift +++ b/ios/thoughts/thoughts/Views/LegalView.swift @@ -65,23 +65,21 @@ struct ImpressumContent: View { // ⚠️ PFLICHTANGABEN — vor Launch ausfüllen! LegalParagraph(title: "Angaben gemäß § 5 TMG") { """ - [DEIN NAME] - [STRASSE HAUSNUMMER] - [PLZ ORT] + Dennis Konkol + Auf dem Ziegenbrink 2B + 49082 Osnabrück Deutschland - ⚠️ Vor Launch ausfüllen! """ } LegalParagraph(title: "Kontakt") { """ - E-Mail: legal@xxx.dk0.dev + E-Mail: legal@dk0.dev - ⚠️ Vor Launch ausfüllen! """ } LegalParagraph(title: "Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV") { - "[DEIN NAME], [ADRESSE]" + "Dennis Konkol, Auf dem Ziegenbrink 2B, 49082 Osnabrück" } LegalParagraph(title: "Hinweis") { """ diff --git a/ios/thoughts/thoughts/Views/MainTabView.swift b/ios/thoughts/thoughts/Views/MainTabView.swift index 3042f30..2794667 100644 --- a/ios/thoughts/thoughts/Views/MainTabView.swift +++ b/ios/thoughts/thoughts/Views/MainTabView.swift @@ -6,33 +6,41 @@ struct MainTabView: View { @State private var selectedTab = 0 @State private var showCompose = false @State private var showSettings = false + @State private var showWhispers = false var body: some View { ZStack(alignment: .bottom) { - Color.nightBase.ignoresSafeArea() - // Content TabContent( selectedTab: selectedTab, realtime: realtime ) .environmentObject(appState) + .safeAreaPadding(.bottom, 80) // Slightly less space, more room for content // Floating Tab Bar - FloatingTabBar( - selectedTab: $selectedTab, - windowState: appState.windowState, - onCompose: { showCompose = true }, - onSettings: { showSettings = true } - ) + VStack { + Spacer() + FloatingTabBar( + selectedTab: $selectedTab, + windowState: appState.windowState, + onCompose: { showCompose = true }, + onSettings: { showSettings = true }, + onWhispers: { showWhispers = true } + ) + .padding(.horizontal, 16) // Less margin to let it breath + .padding(.bottom, 0) // Let Safe Area handle the gap + } } - .ignoresSafeArea(edges: .bottom) .sheet(isPresented: $showCompose) { ComposeView().environmentObject(appState) } .sheet(isPresented: $showSettings) { SettingsView().environmentObject(appState) } + .sheet(isPresented: $showWhispers) { + WhisperListView() + } .onDisappear { Task { await realtime.stopListening() } } @@ -60,10 +68,7 @@ private struct TabContent: View { .scaleEffect(selectedTab == 1 ? 1 : 0.97) .allowsHitTesting(selectedTab == 1) - ProfileView( - user: appState.currentUser ?? .preview, - isCurrentUser: true - ) + ProfileView() .environmentObject(appState) .opacity(selectedTab == 2 ? 1 : 0) .scaleEffect(selectedTab == 2 ? 1 : 0.97) @@ -80,6 +85,7 @@ struct FloatingTabBar: View { let windowState: AppState.WindowState let onCompose: () -> Void let onSettings: () -> Void + let onWhispers: () -> Void var body: some View { HStack(spacing: 0) { @@ -112,25 +118,23 @@ struct FloatingTabBar: View { Spacer() + // Whispers + TabIcon(icon: "bubble.left", activeIcon: "bubble.left.fill", isSelected: false) { + Haptics.select() + onWhispers() + } + + Spacer() + // Settings TabIcon(icon: "gearshape", activeIcon: "gearshape.fill", isSelected: false) { Haptics.select() onSettings() } } - .padding(.horizontal, 20) - .padding(.vertical, 10) - .padding(.bottom, 18) - .background( - Rectangle() - .fill(.ultraThinMaterial.opacity(0.8)) - .background(Color.nightBase.opacity(0.85)) - .ignoresSafeArea() - ) - .overlay( - Rectangle().fill(Color.nightBorder).frame(height: 1), - alignment: .top - ) + .padding(.vertical, 8) + .frame(maxWidth: 400) // Safety cap for larger devices + .glassStyle(opacity: 0.5) } } @@ -157,12 +161,11 @@ struct ComposeTabButton: View { var body: some View { Button { - guard windowState == .open else { return } Haptics.medium() onTap() } label: { ZStack { - if windowState == .open { + if windowState == .open || windowState == .posted { Circle() .fill(Color.nightPurple.opacity(0.18)) .frame(width: 62, height: 62) @@ -174,7 +177,7 @@ struct ComposeTabButton: View { .frame(width: 50, height: 50) Image(systemName: buttonIcon) .font(.system(size: 19, weight: .semibold)) - .foregroundColor(.white) + .foregroundColor(buttonIconColor) } } .onAppear { glow = true } @@ -190,19 +193,30 @@ struct ComposeTabButton: View { )) case .posted: return AnyShapeStyle(LinearGradient( - colors: [Color(hex: "059669"), Color(hex: "047857")], - startPoint: .top, endPoint: .bottom + colors: [Color(hex: "8B5CF6"), Color(hex: "6D28D9")], + startPoint: .topLeading, endPoint: .bottomTrailing + )) + case .closed, .missed: + return AnyShapeStyle(LinearGradient( + colors: [Color(hex: "8B5CF6").opacity(0.6), Color(hex: "6D28D9").opacity(0.6)], + startPoint: .topLeading, endPoint: .bottomTrailing )) - default: - return AnyShapeStyle(Color.nightRaised) } } var buttonIcon: String { switch windowState { - case .open: return "plus" - case .posted: return "checkmark" - default: return "moon.zzz" + case .open: return "plus" + case .posted: return "plus" + case .closed: return "plus.moon" + case .missed: return "plus.moon" + } + } + + var buttonIconColor: Color { + switch windowState { + case .open, .posted: return .white + case .closed, .missed: return .white.opacity(0.85) } } } diff --git a/ios/thoughts/thoughts/Views/OnboardingView.swift b/ios/thoughts/thoughts/Views/OnboardingView.swift index d6ec48f..ef2d09a 100644 --- a/ios/thoughts/thoughts/Views/OnboardingView.swift +++ b/ios/thoughts/thoughts/Views/OnboardingView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Combine struct OnboardingView: View { @EnvironmentObject var appState: AppState @@ -8,98 +9,68 @@ struct OnboardingView: View { enum Phase { case welcome, auth } var body: some View { - ZStack { - Color.nightBase.ignoresSafeArea() - StarField() + GeometryReader { geo in + ZStack { + // Background tap to dismiss + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } - 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 } + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + switch phase { + case .welcome: + WelcomeScreen(onStart: { phase = .auth }, onLogin: { phase = .auth; isLogin = true }) + .transition(.move(edge: .leading).combined(with: .opacity)) + case .auth: + AuthScreen(isLogin: $isLogin) + .transition(.move(edge: .trailing).combined(with: .opacity)) } - ) - - case .auth: - AuthScreen(isLogin: $isLogin) - .environmentObject(appState) - .transition(.move(edge: .trailing).combined(with: .opacity)) - Spacer() + } + .frame(minHeight: geo.size.height) } } } + .animation(.spring(duration: 0.6, bounce: 0.2), value: phase) .preferredColorScheme(.dark) } } -// MARK: - Welcome +// MARK: - Welcome Screen 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)) + VStack(spacing: 40) { + VStack(spacing: 16) { + Text("nightly") + .font(.nightTitle(56)) + .foregroundColor(.nightPrimary) + .tracking(-1) + + Text("Gedanken, die nur nachts existieren.") + .font(.nightBody(18)) + .foregroundColor(.nightSecondary) + .multilineTextAlignment(.center) + } + + VStack(spacing: 14) { + Button("Loslegen", action: onStart).buttonStyle(NightlyPrimaryButton()) + + Button("Ich hab schon einen Account") { + Haptics.select() + onLogin() + } + .font(.nightLabel(15)) .foregroundColor(.nightSecondary) + .padding(.top, 4) + } } - .padding(.horizontal, 24) - .padding(.bottom, 52) + .padding(32) } } @@ -109,7 +80,6 @@ struct AuthScreen: View { @EnvironmentObject var appState: AppState @Binding var isLogin: Bool - // Registrierung @State private var username = "" @State private var displayName = "" @State private var email = "" @@ -117,53 +87,81 @@ struct AuthScreen: View { @State private var isLoading = false @State private var error: String? + @State private var infoMessage: String? + @State private var resetCooldown = 0 + let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() var body: some View { - VStack(spacing: 22) { - Text(isLogin ? "willkommen zurück" : "mitmachen") - .font(.nightTitle(28)) - .foregroundColor(.nightPrimary) + VStack(spacing: 28) { + VStack(spacing: 8) { + Text(isLogin ? "Willkommen zurück" : "Mitmachen") + .font(.nightTitle(32)) + .foregroundColor(.nightPrimary) + + Text(isLogin ? "Logge dich in dein System ein." : "Erstelle deinen anonymen Zugang.") + .font(.nightBody(15)) + .foregroundColor(.nightSecondary) + } - VStack(spacing: 10) { + VStack(spacing: 12) { if !isLogin { - NightlyField(text: $displayName, placeholder: "anzeigename", icon: "person") - NightlyField(text: $username, placeholder: "benutzername", icon: "at") + 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") + NightlyField(text: $email, placeholder: "E-Mail", icon: "envelope") .textInputAutocapitalization(.never) .keyboardType(.emailAddress) .autocorrectionDisabled() - NightlyField(text: $password, placeholder: "passwort", icon: "lock", isSecure: true) + 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) + } + + if let info = infoMessage { + Text(info) + .font(.nightLabel(13)) + .foregroundColor(.nightGreen) + .multilineTextAlignment(.center) } - Button(isLogin ? "einloggen" : "account erstellen") { - Task { await submit() } - } - .buttonStyle(NightlyPrimaryButton(isLoading: isLoading)) - .disabled(isLoading) - .padding(.horizontal, 24) + VStack(spacing: 16) { + Button(isLogin ? "Einloggen" : "Account erstellen") { + Task { await submit() } + } + .buttonStyle(NightlyPrimaryButton(isLoading: isLoading)) + .disabled(isLoading) - Button(isLogin ? "noch kein account?" : "schon dabei?") { - withAnimation { isLogin.toggle() } - } - .font(.nightLabel(14)) - .foregroundColor(.nightSecondary) + if isLogin { + Button(resetCooldown > 0 ? "Wiederholen in \(resetCooldown)s" : "Passwort vergessen?") { + Task { await forgotPassword() } + } + .font(.nightLabel(13)) + .foregroundColor(resetCooldown > 0 ? .nightTertiary : .nightPurpleSoft) + .disabled(resetCooldown > 0) + .onReceive(timer) { _ in + if resetCooldown > 0 { resetCooldown -= 1 } + } + } + + Button(isLogin ? "Noch kein Account? Mitmachen" : "Schon dabei? Einloggen") { + Haptics.select() + withAnimation { isLogin.toggle() } + } + .font(.nightLabel(14)) + .foregroundColor(.nightSecondary) + } - // Rechtliches LegalNotice() } + .padding(32) } func submit() async { @@ -173,6 +171,7 @@ struct AuthScreen: View { } isLoading = true error = nil + infoMessage = nil defer { isLoading = false } do { @@ -202,8 +201,31 @@ struct AuthScreen: View { self.error = error.localizedDescription } } + + func forgotPassword() async { + guard !email.isEmpty else { + error = "Bitte gib deine E-Mail Adresse ein." + return + } + isLoading = true + error = nil + infoMessage = nil + defer { isLoading = false } + + do { + try await appState.resetPassword(email: email) + infoMessage = "Recovery-Link wurde gesendet (Prüfe auch Spam)." + resetCooldown = 60 + Haptics.success() + } catch { + self.error = error.localizedDescription + Haptics.error() + } + } } +// MARK: - Reusable components + struct LegalNotice: View { @State private var showLegal = false @@ -214,9 +236,9 @@ struct LegalNotice: View { .foregroundColor(.nightTertiary) HStack(spacing: 4) { - Button("Nutzungsbedingungen") { showLegal = true } + Button("AGB") { showLegal = true } Text("·") - Button("Datenschutzerklärung") { showLegal = true } + Button("Datenschutz") { showLegal = true } } .font(.nightLabel(11, weight: .medium)) .foregroundColor(.nightSecondary) @@ -228,8 +250,6 @@ struct LegalNotice: View { } } -// MARK: - Reusable components - struct NightlyField: View { @Binding var text: String let placeholder: String @@ -247,13 +267,18 @@ struct NightlyField: View { if isSecure { SecureField(placeholder, text: $text) } else { TextField(placeholder, text: $text) } } - .font(.nightBody(16)) + .font(.nightBody(17)) .foregroundColor(.nightPrimary) } .padding(16) - .background(Color.nightSurface) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Color.nightBorder, lineWidth: 1)) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.ultraThinMaterial.opacity(0.3)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.white.opacity(0.1), lineWidth: 0.5) + ) .tint(.nightPurpleSoft) } } @@ -262,45 +287,19 @@ struct NightlyPrimaryButton: ButtonStyle { var isLoading = false func makeBody(configuration: Configuration) -> some View { Group { - if isLoading { ProgressView().tint(.nightBase).frame(maxWidth: .infinity, maxHeight: 52) } + if isLoading { ProgressView().tint(.nightBase).frame(maxWidth: .infinity, maxHeight: 56) } else { configuration.label .font(.nightLabel(17, weight: .semibold)) .foregroundColor(.nightBase) - .frame(maxWidth: .infinity).frame(height: 52) + .frame(maxWidth: .infinity).frame(height: 56) } } - .background(Color.nightPrimary.opacity(configuration.isPressed ? 0.85 : 1)) - .clipShape(RoundedRectangle(cornerRadius: 14)) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color.nightPrimary.opacity(configuration.isPressed ? 0.8 : 1.0)) + ) .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 } - } - } -} diff --git a/ios/thoughts/thoughts/Views/OtherProfileView.swift b/ios/thoughts/thoughts/Views/OtherProfileView.swift new file mode 100644 index 0000000..d77c0a2 --- /dev/null +++ b/ios/thoughts/thoughts/Views/OtherProfileView.swift @@ -0,0 +1,301 @@ +import Combine +import SwiftUI + +struct OtherProfileView: View { + let userId: UUID + @StateObject private var viewModel: ProfileViewModel + @EnvironmentObject var appState: AppState + @Environment(\.dismiss) var dismiss + @State private var showWhisper = false + @State private var whisperText = "" + @State private var isSendingWhisper = false + + init(userId: UUID) { + self.userId = userId + self._viewModel = StateObject(wrappedValue: ProfileViewModel(userId: userId)) + } + + var body: some View { + NavigationStack { + ZStack { + EtherealBackground().ignoresSafeArea() + + ScrollView { + VStack(spacing: 24) { + if viewModel.isLoading { + ProgressView().tint(.nightPurpleSoft).padding(.top, 80) + } else if let profile = viewModel.profile { + profileContent(profile) + } + } + .padding(.bottom, 120) + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.nightSecondary) + } + } + } + } + .preferredColorScheme(.dark) + .sheet(isPresented: $showWhisper) { whisperSheet } + .task { await viewModel.loadProfile() } + } + + @ViewBuilder + func profileContent(_ profile: Profile) -> some View { + VStack(spacing: 20) { + AvatarView(user: 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: viewModel.isFollowing, + isPro: profile.isPro, isAdmin: profile.isAdmin + ), size: 90) + .shadow(color: .black.opacity(0.2), radius: 20, y: 10) + + VStack(spacing: 4) { + HStack(spacing: 6) { + Text(profile.displayName) + .font(.nightTitle(26)) + .foregroundColor(.nightPrimary) + + if profile.isPro { + Image(systemName: "moon.stars.fill") + .font(.system(size: 12)) + .foregroundColor(.nightPurple) + } + } + + Text("@\(profile.username)") + .font(.nightLabel(15)) + .foregroundColor(.nightPurpleSoft) + } + + if let bio = profile.bio, !bio.isEmpty { + Text(bio) + .font(.nightBody(15)) + .foregroundColor(.nightSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + + HStack(spacing: 32) { + StatBadge(value: viewModel.postCount, label: "Gedanken") + StatBadge(value: viewModel.resonanceCount, label: "Resonanzen") + StatBadge(value: viewModel.streak, label: "Nächte") + } + .padding(.top, 4) + + if !viewModel.isOwnProfile { + HStack(spacing: 12) { + followButton + + Button { showWhisper = true } label: { + HStack(spacing: 6) { + Image(systemName: "bubble.left") + .font(.system(size: 13)) + Text("Zuflüstern") + .font(.nightLabel(14, weight: .semibold)) + } + .foregroundColor(.nightPrimary) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(.white.opacity(0.06))) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Color.white.opacity(0.1), lineWidth: 0.5)) + } + } + .padding(.top, 4) + } + } + .padding(.top, 30) + + if !viewModel.posts.isEmpty { + VStack(alignment: .leading, spacing: 16) { + Text("Gedanken") + .font(.nightLabel(16, weight: .bold)) + .foregroundColor(.nightPrimary) + .padding(.horizontal, 16) + + ForEach(viewModel.posts) { post in + PostRowView(post: post) {} + .padding(.horizontal, 16) + } + } + .padding(.top, 20) + } + } + + var followButton: some View { + Button { + Task { await viewModel.toggleFollow() } + } label: { + HStack(spacing: 6) { + Image(systemName: viewModel.isFollowing ? "person.fill.checkmark" : "person.badge.plus") + .font(.system(size: 13)) + Text(viewModel.isFollowing ? "Gefolgt" : "Folgen") + .font(.nightLabel(14, weight: .semibold)) + } + .foregroundColor(viewModel.isFollowing ? .nightGreen : .white) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(viewModel.isFollowing ? Color.nightGreen.opacity(0.15) : Color.nightPurple)) + } + } + + var whisperSheet: some View { + NavigationStack { + ZStack { + EtherealBackground().ignoresSafeArea() + + VStack(spacing: 20) { + Text("Flüstern ist anonym — der andere weiß nicht, von wem die Nachricht kommt.") + .font(.nightLabel(13)) + .foregroundColor(.nightTertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + + TextField("Was möchtest du zuflüstern?", text: $whisperText, axis: .vertical) + .font(.nightBody(17)) + .foregroundColor(.nightPrimary) + .lineLimit(3...6) + .padding(16) + .background(RoundedRectangle(cornerRadius: 16).fill(.ultraThinMaterial.opacity(0.3))) + .overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.white.opacity(0.1), lineWidth: 0.5)) + + Spacer() + } + .padding(24) + .padding(.top, 20) + } + .navigationTitle("Zuflüstern") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Abbrechen") { showWhisper = false; whisperText = "" } + .foregroundColor(.nightSecondary) + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Senden") { Task { await sendWhisper() } } + .font(.nightLabel(16, weight: .bold)) + .foregroundColor(.nightPurpleSoft) + .disabled(whisperText.trimmingCharacters(in: .whitespaces).isEmpty || isSendingWhisper) + } + } + } + .preferredColorScheme(.dark) + } + + func sendWhisper() async { + let text = whisperText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + isSendingWhisper = true + do { + try await supabase.sendWhisper(toUserId: userId, content: text, postId: nil) + Haptics.success() + showWhisper = false + whisperText = "" + } catch { + Haptics.error() + } + isSendingWhisper = false + } +} + +struct StatBadge: View { + let value: Int + let label: String + + var body: some View { + VStack(spacing: 4) { + Text("\(value)") + .font(.nightLabel(20, weight: .bold)) + .foregroundColor(.nightPrimary) + Text(label) + .font(.nightLabel(11)) + .foregroundColor(.nightTertiary) + } + } +} + +@MainActor +class ProfileViewModel: ObservableObject { + @Published var profile: Profile? + @Published var posts: [Post] = [] + @Published var streak: Int = 0 + @Published var postCount: Int = 0 + @Published var resonanceCount: Int = 0 + @Published var isFollowing: Bool = false + @Published var isOwnProfile: Bool = false + @Published var isLoading = true + + let userId: UUID + + init(userId: UUID) { + self.userId = userId + } + + func loadProfile() async { + isLoading = true + defer { isLoading = false } + + do { + async let profileTask = supabase.getPublicProfile(userId: userId) + async let postsTask = supabase.getUserPosts(userId: userId) + async let streakTask = supabase.getStreak(userId: userId) + async let statsTask = supabase.getProfileStats(userId: userId) + + let (user, userPosts, streakVal, stats) = try await (profileTask, postsTask, streakTask, statsTask) + + isOwnProfile = false + posts = userPosts + postCount = stats.postCount + resonanceCount = stats.resonanceCount + streak = streakVal + + if let currentId = await supabase.currentUserId { + isOwnProfile = currentId == userId + if !isOwnProfile { + isFollowing = await supabase.isFollowing(userId: userId) + } + } + + profile = Profile( + id: userId, + username: user.username, + displayName: user.displayName, + bio: user.bio, + avatarUrl: user.avatarURL?.absoluteString, + isPro: user.isPro, + isAdmin: user.isAdmin, + createdAt: Date() + ) + } catch { + profile = nil + } + } + + func toggleFollow() async { + isFollowing.toggle() + do { + if isFollowing { + try await supabase.follow(userId: userId) + Haptics.light() + } else { + try await supabase.unfollow(userId: userId) + Haptics.medium() + } + } catch { + isFollowing.toggle() + Haptics.error() + } + } +} \ No newline at end of file diff --git a/ios/thoughts/thoughts/Views/PostRowView.swift b/ios/thoughts/thoughts/Views/PostRowView.swift index dae729f..a78c206 100644 --- a/ios/thoughts/thoughts/Views/PostRowView.swift +++ b/ios/thoughts/thoughts/Views/PostRowView.swift @@ -1,292 +1,214 @@ import SwiftUI -// MARK: - Post Row - struct PostRowView: View { let post: Post let onResonate: () -> Void var onReport: (() -> Void)? = nil @State private var showReport = false + @State private var isContentRevealed = false + @State private var showProfile = 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) + VStack(alignment: .leading, spacing: 0) { + moodAccentBar + mainContent } + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) .sheet(isPresented: $showReport) { ReportSheet(postId: post.id) } } -} -// MARK: - Resonance Button - -struct ResonanceButton: View { - let count: Int - let isActive: Bool - let action: () -> Void - - @State private var scale: CGFloat = 1.0 - - var body: some View { - Button { - Haptics.light() - withAnimation(.spring(duration: 0.25, bounce: 0.7)) { scale = 1.4 } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { - withAnimation(.spring(duration: 0.2)) { scale = 1.0 } - } - action() - } label: { - HStack(spacing: 5) { - Image(systemName: isActive ? "heart.fill" : "heart") - .font(.system(size: 14)) - .foregroundColor(isActive ? .nightRed : .nightSecondary) - .scaleEffect(scale) - - Text(count > 0 ? "\(count)" : "hat mich getroffen") - .font(.nightLabel(13)) - .foregroundColor(isActive ? .nightRed : .nightSecondary) - } - .padding(.vertical, 5) - .padding(.horizontal, count > 0 || isActive ? 10 : 0) - .background( - Capsule() - .fill(isActive ? Color.nightRed.opacity(0.1) : Color.clear) - ) - } - .animation(.easeInOut(duration: 0.2), value: isActive) - } -} - -// MARK: - Avatar - -struct AvatarView: View { - let user: User - let size: CGFloat - - var body: some View { + var moodAccentBar: 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) + if let mood = post.mood { + LinearGradient( + colors: mood.colorGradient.map { $0.opacity(0.6) }, + startPoint: .top, + endPoint: .bottom + ) + .frame(width: 3) + } else { + Color.nightTertiary.opacity(0.15) + .frame(width: 1) + } } } -} -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) + var mainContent: some View { + VStack(alignment: .leading, spacing: 14) { + header + + if post.hasContentWarning && !isContentRevealed { + contentWarningOverlay + } else { + contentText + } + + footer } - .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" + .padding(18) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial.opacity(0.35)) + ) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke( + moodBorderColor, + lineWidth: 0.5 + ) + ) } - var body: some View { - NavigationStack { - ZStack { - Color.nightSurface.ignoresSafeArea() + var moodBorderColor: Color { + if let mood = post.mood { + return mood.color.opacity(0.12) + } + return Color.white.opacity(0.06) + } - 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) + var header: some View { + HStack(spacing: 10) { + if post.isAnonymous { + AnonymousAvatar(size: 30) + } else { + Button { + if let uuid = UUID(uuidString: post.author.id) { + showProfile = true } - .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) + } label: { + HStack(spacing: 10) { + AvatarView(user: post.author, size: 30) - 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) - } + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 5) { + Text(post.author.displayName) + .font(.nightLabel(14, weight: .semibold)) + .foregroundColor(.nightPrimary) + .lineLimit(1) - 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) + if let mood = post.mood { + Text(mood.emoji) + .font(.system(size: 11)) } } - .frame(maxWidth: .infinity) - .frame(height: 50) - .background(selected != nil ? Color.nightPrimary : Color.nightRaised) - .clipShape(RoundedRectangle(cornerRadius: 14)) + + Text(post.timeAgo) + .font(.nightLabel(11)) + .foregroundColor(.nightTertiary) } - .disabled(selected == nil || isLoading) - .padding(.horizontal, 20) - .padding(.bottom, 32) } } + .buttonStyle(.plain) } - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Abbrechen") { dismiss() } - .foregroundColor(.nightSecondary) + + if post.isAnonymous { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 5) { + Text("anonym") + .font(.nightLabel(14, weight: .semibold)) + .foregroundColor(.nightTertiary) + + if let mood = post.mood { + Text(mood.emoji) + .font(.system(size: 11)) + } + } + + Text(post.timeAgo) + .font(.nightLabel(11)) + .foregroundColor(.nightTertiary) + } + } + + Spacer() + + if post.hasContentWarning { + Image(systemName: "exclamationmark.shield.fill") + .font(.system(size: 11)) + .foregroundColor(.nightRed.opacity(0.5)) + } + } + .sheet(isPresented: $showProfile) { + if let uuid = UUID(uuidString: post.author.id) { + OtherProfileView(userId: uuid) + } + } + } + + var contentText: some View { + VStack(alignment: .leading, spacing: 6) { + Text(post.content) + .font(.nightBody(16)) + .foregroundColor(.nightPrimary.opacity(0.92)) + .lineSpacing(5) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + + if post.hasContentWarning { + Button { + withAnimation(.spring(duration: 0.3)) { isContentRevealed = false } + } label: { + HStack(spacing: 3) { + Image(systemName: "eye.slash") + .font(.system(size: 9)) + Text("verbergen") + .font(.nightLabel(10)) + } + .foregroundColor(.nightTertiary) } } } - .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 + var contentWarningOverlay: some View { + Button { + withAnimation(.spring(duration: 0.4)) { isContentRevealed = true } + Haptics.light() + } label: { + VStack(spacing: 8) { + Image(systemName: post.contentWarningType?.icon ?? "exclamationmark.triangle") + .font(.system(size: 22)) + .foregroundColor(.nightRed.opacity(0.6)) + + VStack(spacing: 2) { + Text(post.contentWarningType?.shortLabel ?? "Achtung") + .font(.nightLabel(13, weight: .semibold)) + .foregroundColor(.nightPrimary) + Text("Tippe um den Inhalt zu sehen") + .font(.nightLabel(11)) + .foregroundColor(.nightTertiary) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) } } -} + + var footer: some View { + HStack(spacing: 0) { + ResonanceButton( + count: post.resonanceCount, + isActive: post.hasResonated, + action: onResonate + ) + + Spacer() + + Menu { + Button(role: .destructive) { + showReport = true + } label: { + Label("Melden", systemImage: "flag") + } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 13)) + .foregroundColor(.nightTertiary) + .padding(8) + } + } + } +} \ No newline at end of file diff --git a/ios/thoughts/thoughts/Views/ProfileView.swift b/ios/thoughts/thoughts/Views/ProfileView.swift index 92011ce..306ed56 100644 --- a/ios/thoughts/thoughts/Views/ProfileView.swift +++ b/ios/thoughts/thoughts/Views/ProfileView.swift @@ -1,356 +1,229 @@ +import Combine import SwiftUI struct ProfileView: View { - let user: User - let isCurrentUser: Bool @EnvironmentObject var appState: AppState - @StateObject private var viewModel: ProfileViewModel - @State private var showSettings = false - @State private var postsAppeared = false - - init(user: User, isCurrentUser: Bool) { - self.user = user - self.isCurrentUser = isCurrentUser - _viewModel = StateObject(wrappedValue: ProfileViewModel(userIdString: user.id)) - } + @State private var isEditing = false + @State private var displayName = "" + @State private var bio = "" + @State private var isLoading = false + @State private var errorMessage: String? + @StateObject private var viewModel = ProfileViewModel2() var body: some View { NavigationStack { ZStack { - Color.nightBase.ignoresSafeArea() + EtherealBackground().ignoresSafeArea() ScrollView { - VStack(spacing: 0) { - if viewModel.isLoading { - SkeletonProfileHeader() - } else { - ProfileHeader( - user: user, - streak: viewModel.streak, - isCurrentUser: isCurrentUser - ) - } - - Divider().background(Color.nightBorder) - - // Posts - if viewModel.isLoading { - ForEach(0..<4, id: \.self) { _ in - SkeletonPostRow() - Divider().background(Color.nightBorder).padding(.leading, 16) - } - } else if viewModel.posts.isEmpty { - EmptyProfilePosts() - } else { - LazyVStack(spacing: 0) { - ForEach(Array(viewModel.posts.enumerated()), id: \.element.id) { index, post in - PostRowView(post: post) {} - .opacity(postsAppeared ? 1 : 0) - .offset(y: postsAppeared ? 0 : 16) - .animation( - .spring(duration: 0.4, bounce: 0.12) - .delay(Double(min(index, 8)) * 0.05), - value: postsAppeared - ) - Divider().background(Color.nightBorder).padding(.leading, 16) + VStack(spacing: 32) { + if let user = appState.currentUser { + VStack(spacing: 20) { + AvatarView(user: user, size: 100) + .shadow(color: .black.opacity(0.2), radius: 20, y: 10) + + VStack(spacing: 4) { + Text(user.displayName) + .font(.nightTitle(28)) + .foregroundColor(.nightPrimary) + + Text("@\(user.username)") + .font(.nightLabel(16)) + .foregroundColor(.nightPurpleSoft) } + + if let userBio = user.bio, !userBio.isEmpty { + Text(userBio) + .font(.nightBody(16)) + .foregroundColor(.nightSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + + Button { + displayName = user.displayName + bio = user.bio ?? "" + isEditing = true + } label: { + Text("Profil bearbeiten") + .font(.nightLabel(14, weight: .semibold)) + .padding(.horizontal, 24) + .padding(.vertical, 10) + .background(.white.opacity(0.05)) + .clipShape(Capsule()) + .overlay(Capsule().stroke(Color.white.opacity(0.1), lineWidth: 0.5)) + } + .foregroundColor(.nightPrimary) } - .onAppear { postsAppeared = true } + .padding(.top, 40) + } else { + ProgressView().tint(.nightPurpleSoft) + .padding(.top, 100) } - - Color.clear.frame(height: 100) + + VStack(alignment: .leading, spacing: 16) { + Text("Deine Aktivitäten") + .font(.nightLabel(18, weight: .bold)) + .foregroundColor(.nightPrimary) + .padding(.leading, 8) + + VStack(spacing: 12) { + ActionRow(icon: "heart.fill", title: "Resonanzen erhalten", value: viewModel.isLoading ? "…" : "\(viewModel.resonanceCount)") + ActionRow(icon: "text.bubble.fill", title: "Gedanken geteilt", value: viewModel.isLoading ? "…" : "\(viewModel.postCount)") + ActionRow(icon: "moon.stars.fill", title: "Nächte in Folge", value: viewModel.isLoading ? "…" : "\(viewModel.streak)") + } + } + .padding(.horizontal, 16) } + .padding(.bottom, 120) } } + .navigationTitle("Profil") .navigationBarTitleDisplayMode(.inline) - .toolbar { - if isCurrentUser { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - showSettings = true - } label: { - Image(systemName: "gearshape") - .foregroundColor(.nightSecondary) - } - } - } - } - .sheet(isPresented: $showSettings) { - SettingsView().environmentObject(appState) + .sheet(isPresented: $isEditing) { + EditProfileSheet(displayName: $displayName, bio: $bio, onSave: saveProfile) } } .task { await viewModel.load() } } -} -// MARK: - Profile Header - -struct ProfileHeader: View { - let user: User - let streak: Int - let isCurrentUser: Bool - - @State private var isFollowing: Bool - @State private var isFollowLoading = false - - // Staggered entrance - @State private var avatarAppeared = false - @State private var infoAppeared = false - @State private var statsAppeared = false - @State private var actionAppeared = false - - init(user: User, streak: Int, isCurrentUser: Bool) { - self.user = user - self.streak = streak - self.isCurrentUser = isCurrentUser - _isFollowing = State(initialValue: user.isFollowing) - } - - var body: some View { - VStack(spacing: 0) { - VStack(spacing: 16) { - // Avatar mit Glow Ring - ZStack { - Circle() - .fill( - RadialGradient( - colors: [Color.nightPurple.opacity(0.12), .clear], - center: .center, - startRadius: 34, - endRadius: 52 - ) - ) - .frame(width: 96, height: 96) - - Circle() - .strokeBorder( - LinearGradient( - colors: [Color.nightPurple.opacity(0.35), Color.nightPurpleSoft.opacity(0.1)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ), - lineWidth: 1.5 - ) - .frame(width: 82, height: 82) - - AvatarView(user: user, size: 76) - } - .scaleEffect(avatarAppeared ? 1 : 0.8) - .opacity(avatarAppeared ? 1 : 0) - - // Name, Username, Bio - VStack(spacing: 4) { - Text(user.displayName) - .font(.nightTitle(22)) - .foregroundColor(.nightPrimary) - Text("@\(user.username)") - .font(.nightLabel(14)) - .foregroundColor(.nightSecondary) - if let bio = user.bio { - Text(bio) - .font(.nightBody(14)) - .foregroundColor(.nightPrimary.opacity(0.75)) - .multilineTextAlignment(.center) - .padding(.top, 4) - } - } - .opacity(infoAppeared ? 1 : 0) - .offset(y: infoAppeared ? 0 : 8) - - // Stats - HStack(spacing: 36) { - ProfileStat(value: user.postCount, label: "nächte") - ProfileStat(value: user.followerCount, label: "follower") - ProfileStat(value: user.followingCount, label: "following") - } - .opacity(statsAppeared ? 1 : 0) - .offset(y: statsAppeared ? 0 : 10) - - // Streak - if streak > 0 { - StreakBadge(streak: streak) - .opacity(actionAppeared ? 1 : 0) - .offset(y: actionAppeared ? 0 : 8) - } - - // Action button - if isCurrentUser { - Button("profil bearbeiten") {} - .font(.nightLabel(14, weight: .medium)) - .foregroundColor(.nightPrimary) - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .background(Color.nightRaised) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .overlay( - RoundedRectangle(cornerRadius: 10) - .strokeBorder(Color.nightBorder, lineWidth: 1) - ) - .padding(.horizontal, 48) - .opacity(actionAppeared ? 1 : 0) - .offset(y: actionAppeared ? 0 : 8) - } else { - FollowButton( - isFollowing: $isFollowing, - isLoading: $isFollowLoading, - onToggle: { Task { await toggleFollow() } } - ) - .opacity(actionAppeared ? 1 : 0) - .offset(y: actionAppeared ? 0 : 8) - } - } - .padding(.horizontal, 20) - .padding(.top, 28) - .padding(.bottom, 20) - } - .onAppear { - withAnimation(.spring(duration: 0.6, bounce: 0.3).delay(0.05)) { avatarAppeared = true } - withAnimation(.spring(duration: 0.6, bounce: 0.2).delay(0.15)) { infoAppeared = true } - withAnimation(.spring(duration: 0.5, bounce: 0.2).delay(0.25)) { statsAppeared = true } - withAnimation(.spring(duration: 0.5, bounce: 0.2).delay(0.35)) { actionAppeared = true } - } - } - - func toggleFollow() async { - Haptics.light() - isFollowLoading = true - defer { isFollowLoading = false } - guard let uid = UUID(uuidString: user.id) else { return } + func saveProfile() async { + isLoading = true + errorMessage = nil do { - if isFollowing { - try await supabase.unfollow(userId: uid) - } else { - try await supabase.follow(userId: uid) - } - withAnimation(.spring(duration: 0.3, bounce: 0.4)) { - isFollowing.toggle() + try await supabase.updateProfile(displayName: displayName, bio: bio) + if var user = appState.currentUser { + user.displayName = displayName + user.bio = bio + appState.currentUser = user } + isEditing = false Haptics.success() } catch { + errorMessage = error.localizedDescription Haptics.error() } + isLoading = false } } -// MARK: - Follow Button +@MainActor +class ProfileViewModel2: ObservableObject { + @Published var postCount = 0 + @Published var resonanceCount = 0 + @Published var streak = 0 + @Published var isLoading = false -struct FollowButton: View { - @Binding var isFollowing: Bool - @Binding var isLoading: Bool - let onToggle: () -> Void + func load() async { + isLoading = true + defer { isLoading = false } - var body: some View { - Button(action: onToggle) { - Group { - if isLoading { - ProgressView().tint(isFollowing ? .nightPrimary : .nightBase) - } else { - Text(isFollowing ? "entfolgen" : "folgen") - .font(.nightLabel(14, weight: .semibold)) - .foregroundColor(isFollowing ? .nightPrimary : .nightBase) - } - } - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .background(isFollowing ? Color.nightRaised : Color.nightPrimary) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .overlay( - RoundedRectangle(cornerRadius: 10) - .strokeBorder(isFollowing ? Color.nightBorder : .clear, lineWidth: 1) - ) + guard let uid = await supabase.currentUserId else { return } + + do { + async let statsTask = supabase.getProfileStats(userId: uid) + async let streakTask = supabase.getStreak(userId: uid) + let (stats, streakVal) = try await (statsTask, streakTask) + postCount = stats.postCount + resonanceCount = stats.resonanceCount + streak = streakVal + } catch { + let diary = (try? await supabase.getDiary()) ?? [] + postCount = diary.count } - .disabled(isLoading) - .padding(.horizontal, 48) - .animation(.spring(duration: 0.3, bounce: 0.3), value: isFollowing) } } -// MARK: - Streak Badge - -struct StreakBadge: View { - let streak: Int - @State private var fireScale: CGFloat = 1.0 - - var isHot: Bool { streak >= 7 } +struct EditProfileSheet: View { + @Binding var displayName: String + @Binding var bio: String + @Environment(\.dismiss) var dismiss + var onSave: () async -> Void + @State private var isSaving = false var body: some View { - HStack(spacing: 6) { - Image(systemName: isHot ? "flame.fill" : "flame") - .foregroundColor(isHot ? .orange : .nightSecondary) - .scaleEffect(fireScale) - Text("\(streak) Nächte in Folge") - .font(.nightLabel(13, weight: isHot ? .semibold : .regular)) - .foregroundColor(isHot ? .nightPrimary : .nightSecondary) - } - .padding(.horizontal, 14) - .padding(.vertical, 7) - .background( + NavigationStack { ZStack { - Capsule().fill(Color.nightRaised) - if isHot { - Capsule().fill(Color.orange.opacity(0.06)) - Capsule().strokeBorder(Color.orange.opacity(0.2), lineWidth: 1) + EtherealBackground().ignoresSafeArea() + + ScrollView { + VStack(spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + Text("Name") + .font(.nightLabel(14)) + .foregroundColor(.nightSecondary) + NightlyField(text: $displayName, placeholder: "Dein Anzeigename", icon: "person") + } + + VStack(alignment: .leading, spacing: 8) { + Text("Bio") + .font(.nightLabel(14)) + .foregroundColor(.nightSecondary) + + TextEditor(text: $bio) + .scrollContentBackground(.hidden) + .padding(16) + .frame(height: 120) + .background(RoundedRectangle(cornerRadius: 16).fill(.ultraThinMaterial.opacity(0.3))) + .overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.white.opacity(0.1), lineWidth: 0.5)) + .font(.nightBody(16)) + .foregroundColor(.nightPrimary) + } + + Spacer() + } + .padding(24) } } - ) - .onAppear { - guard isHot else { return } - withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { - fireScale = 1.18 + .navigationTitle("Bearbeiten") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Abbrechen") { dismiss() } + .foregroundColor(.nightSecondary) + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Speichern") { + isSaving = true + Task { + await onSave() + isSaving = false + } + } + .font(.nightLabel(16, weight: .bold)) + .foregroundColor(.nightPurpleSoft) + .disabled(isSaving) + } } } + .preferredColorScheme(.dark) } } -// MARK: - Profile Stat - -struct ProfileStat: View { - let value: Int - let label: String - @State private var displayValue: Int = 0 - @State private var appeared = false - +struct ActionRow: View { + let icon: String + let title: String + let value: String + var body: some View { - VStack(spacing: 3) { - Text("\(displayValue)") - .font(.nightTitle(18)) + HStack(spacing: 16) { + Image(systemName: icon) + .foregroundColor(.nightPurpleSoft) + .frame(width: 24) + + Text(title) + .font(.nightBody(16)) .foregroundColor(.nightPrimary) - .contentTransition(.numericText(value: Double(displayValue))) - Text(label) - .font(.nightLabel(12)) + + Spacer() + + Text(value) + .font(.nightLabel(16, weight: .bold)) .foregroundColor(.nightSecondary) } - .onAppear { - guard !appeared else { return } - appeared = true - withAnimation(.easeOut(duration: 0.5).delay(0.3)) { - displayValue = value - } - } - } -} - -// MARK: - Empty State - -struct EmptyProfilePosts: View { - @State private var appeared = false - - var body: some View { - VStack(spacing: 14) { - Image(systemName: "moon.zzz") - .font(.system(size: 36)) - .foregroundColor(.nightTertiary) - Text("noch keine nächte") - .font(.nightLabel(15)) - .foregroundColor(.nightSecondary) - } - .padding(.top, 60) - .opacity(appeared ? 1 : 0) - .offset(y: appeared ? 0 : 12) - .onAppear { - withAnimation(.spring(duration: 0.5, bounce: 0.2).delay(0.2)) { - appeared = true - } - } + .padding(20) + .background(.ultraThinMaterial.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(Color.white.opacity(0.05), lineWidth: 0.5)) } } diff --git a/ios/thoughts/thoughts/Views/RootView.swift b/ios/thoughts/thoughts/Views/RootView.swift index 009079f..18936ca 100644 --- a/ios/thoughts/thoughts/Views/RootView.swift +++ b/ios/thoughts/thoughts/Views/RootView.swift @@ -4,15 +4,35 @@ struct RootView: View { @EnvironmentObject var appState: AppState var body: some View { - Group { - if appState.isAuthenticated { - MainTabView() - .transition(.opacity.combined(with: .scale(scale: 0.98))) - } else { - OnboardingView() - .transition(.opacity.combined(with: .scale(scale: 1.02))) + ZStack { + // Background is global and strictly constrained + EtherealBackground() + .ignoresSafeArea() + + Group { + if appState.isAuthenticated { + MainTabView() + .frame(maxWidth: .infinity) + } else { + OnboardingView() + .frame(maxWidth: .infinity) + } } } .animation(.spring(duration: 0.5, bounce: 0.15), value: appState.isAuthenticated) + .onOpenURL { url in + handleDeepLink(url) + } + .sheet(isPresented: $appState.showResetPassword) { + ResetPasswordView() + .environmentObject(appState) + } + } + + private func handleDeepLink(_ url: URL) { + // thoughts://auth/reset + if url.host == "auth" && url.path == "/reset" { + appState.showResetPassword = true + } } } diff --git a/ios/thoughts/thoughts/Views/SettingsView.swift b/ios/thoughts/thoughts/Views/SettingsView.swift index 1a1bde7..4c4de13 100644 --- a/ios/thoughts/thoughts/Views/SettingsView.swift +++ b/ios/thoughts/thoughts/Views/SettingsView.swift @@ -10,211 +10,183 @@ struct SettingsView: View { @State private var deletePassword = "" @State private var isDeleting = false @State private var deleteError: String? - @State private var notificationsEnabled = false + @StateObject private var settings = AppSettings.shared + @State private var notificationsEnabled = false @State private var appeared = false var body: some View { NavigationStack { ZStack { - Color.nightBase.ignoresSafeArea() + EtherealBackground().ignoresSafeArea() ScrollView { - VStack(spacing: 20) { + VStack(spacing: 24) { - // Account - SettingsSection { - if let user = appState.currentUser { - HStack(spacing: 12) { - ZStack { - Circle() - .fill(Color.nightPurple.opacity(0.1)) - .frame(width: 52, height: 52) - AvatarView(user: user, size: 44) - } - VStack(alignment: .leading, spacing: 2) { - Text(user.displayName) - .font(.nightLabel(15, weight: .semibold)) - .foregroundColor(.nightPrimary) - Text("@\(user.username)") - .font(.nightLabel(13)) - .foregroundColor(.nightSecondary) - } - Spacer() - } - .padding(.vertical, 4) - } - } - .opacity(appeared ? 1 : 0) - .offset(y: appeared ? 0 : 12) - .animation(.spring(duration: 0.4, bounce: 0.15).delay(0.05), value: appeared) - - // Benachrichtigungen - SettingsSection(header: "benachrichtigungen") { - SettingsRow { - HStack { - SettingsIcon(icon: "bell.fill", color: .nightPurple) - VStack(alignment: .leading, spacing: 2) { - Text("nightly ping") - .font(.nightLabel(15)) - .foregroundColor(.nightPrimary) - Text("Wenn das Fenster öffnet") - .font(.nightLabel(12)) - .foregroundColor(.nightSecondary) - } - Spacer() - Toggle("", isOn: $notificationsEnabled) - .tint(.nightPurple) - .labelsHidden() - } - } - Divider().background(Color.nightBorder).padding(.leading, 52) - SettingsRow { - HStack(spacing: 8) { - Image(systemName: "info.circle") - .foregroundColor(.nightTertiary) - .font(.system(size: 13)) - Text("Push-Benachrichtigungen benötigen einen bezahlten Apple Developer Account ($99/Jahr).") - .font(.nightLabel(12)) - .foregroundColor(.nightTertiary) - .lineSpacing(3) - } - .padding(.vertical, 2) - } - } - .opacity(appeared ? 1 : 0) - .offset(y: appeared ? 0 : 12) - .animation(.spring(duration: 0.4, bounce: 0.15).delay(0.1), value: appeared) - - // Rechtliches - SettingsSection(header: "rechtliches") { - SettingsRow { - Button { - showLegal = true - } label: { - HStack { - SettingsIcon(icon: "doc.text", color: .nightSecondary) - Text("Impressum & Datenschutz") - .font(.nightLabel(15)) - .foregroundColor(.nightPrimary) - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.nightTertiary) - } - } - } - Divider().background(Color.nightBorder).padding(.leading, 52) - SettingsRow { - HStack { - SettingsIcon(icon: "info.circle", color: .nightSecondary) - Text("Version") - .font(.nightLabel(15)) + // Account Profile Header + if let user = appState.currentUser { + VStack(spacing: 16) { + AvatarView(user: user, size: 80) + .shadow(color: .black.opacity(0.15), radius: 15, y: 8) + + VStack(spacing: 4) { + Text(user.displayName) + .font(.nightTitle(24)) .foregroundColor(.nightPrimary) - Spacer() - Text(appVersion) - .font(.nightMono(13)) - .foregroundColor(.nightTertiary) + Text("@\(user.username)") + .font(.nightLabel(15)) + .foregroundColor(.nightPurpleSoft) } } + .padding(.vertical, 20) + .frame(maxWidth: .infinity) + .background(RoundedRectangle(cornerRadius: 28, style: .continuous).fill(.ultraThinMaterial.opacity(0.3))) + .overlay(RoundedRectangle(cornerRadius: 28, style: .continuous).stroke(Color.white.opacity(0.05), lineWidth: 0.5)) + .opacity(appeared ? 1 : 0) + .scaleEffect(appeared ? 1 : 0.95) + .animation(.spring(duration: 0.5, bounce: 0.2), value: appeared) } - .opacity(appeared ? 1 : 0) - .offset(y: appeared ? 0 : 12) - .animation(.spring(duration: 0.4, bounce: 0.15).delay(0.15), value: appeared) - // Account-Aktionen - SettingsSection(header: "account") { - SettingsRow { + // App Settings Sections + VStack(spacing: 20) { + + // Haptik & Flow + SettingsSection(header: "Haptik & Flow") { + SettingsToggleRow(icon: "hand.tap.fill", color: .nightPurpleSoft, title: "Haptisches Feedback", isOn: $settings.hapticsEnabled) + + if settings.hapticsEnabled { + Divider().background(Color.nightBorder).padding(.leading, 52) + SettingsRow { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Intensität").font(.nightLabel(13)).foregroundColor(.nightSecondary) + Spacer() + Text("\(Int(settings.hapticsIntensity * 100))%").font(.nightLabel(13, weight: .bold)).foregroundColor(.nightPrimary) + } + Slider(value: $settings.hapticsIntensity, in: 0.1...1.0).tint(.nightPurple) + } + .padding(.leading, 36) + } + } + + Divider().background(Color.nightBorder).padding(.leading, 52) + SettingsToggleRow(icon: "wind", color: .nightGreen, title: "Flüssige Animationen", isOn: $settings.fluidAnimations) + } + + // Benachrichtigungen + SettingsSection(header: "Benachrichtigungen") { + SettingsToggleRow(icon: "bell.fill", color: .nightPurple, title: "Nightly Ping", isOn: $notificationsEnabled) + + Divider().background(Color.nightBorder).padding(.leading, 52) + SettingsRow { + HStack(spacing: 10) { + Image(systemName: "info.circle").foregroundColor(.nightTertiary).font(.system(size: 14)) + Text("Push-Dienste erfordern eine Cloud-Signierung.").font(.nightLabel(12)).foregroundColor(.nightTertiary) + } + } + } + + #if DEBUG + SettingsSection(header: "Developer") { + SettingsToggleRow(icon: "hammer.fill", color: .nightRed, title: "Zeitfenster Bypass", isOn: $settings.bypassWindowLogic) + .onChange(of: settings.bypassWindowLogic) { _, _ in appState.updateWindowState() } + + Divider().background(Color.nightBorder).padding(.leading, 52) + Button(role: .destructive) { + Haptics.warning() + Task { try? await supabase.deleteAllMyPosts() } + } label: { + SettingsActionRow(icon: "trash.fill", color: .nightRed, title: "Alle eigenen Posts löschen") + } + } + #endif + + // Rechtliches & Info + SettingsSection(header: "Info") { + Button { showLegal = true } label: { + SettingsActionRow(icon: "doc.text.fill", color: .nightSecondary, title: "Impressum & Datenschutz", showChevron: true) + } + + Divider().background(Color.nightBorder).padding(.leading, 52) + SettingsRow { + HStack { + SettingsIcon(icon: "info.circle.fill", color: .nightSecondary) + Text("Version").font(.nightLabel(15)).foregroundColor(.nightPrimary) + Spacer() + Text(appVersion).font(.nightLabel(14, weight: .bold)).foregroundColor(.nightTertiary) + } + } + } + + // Account Aktionen + VStack(spacing: 12) { Button { Haptics.medium() appState.signOut() dismiss() } label: { - HStack { - SettingsIcon(icon: "rectangle.portrait.and.arrow.right", color: .nightSecondary) - Text("abmelden") - .font(.nightLabel(15)) - .foregroundColor(.nightPrimary) - Spacer() - } + Text("Abmelden") + .font(.nightLabel(16, weight: .bold)) + .foregroundColor(.nightSecondary) + .frame(maxWidth: .infinity).frame(height: 54) + .background(RoundedRectangle(cornerRadius: 18, style: .continuous).fill(.white.opacity(0.05))) } - } - Divider().background(Color.nightBorder).padding(.leading, 52) - SettingsRow { + Button { Haptics.warning() showDeleteConfirm = true } label: { - HStack { - SettingsIcon(icon: "trash", color: .nightRed) - Text("account löschen") - .font(.nightLabel(15)) - .foregroundColor(.nightRed) - Spacer() - } + Text("Account löschen") + .font(.nightLabel(14, weight: .medium)) + .foregroundColor(.nightRed.opacity(0.8)) } + .padding(.top, 8) } + .padding(.top, 12) } .opacity(appeared ? 1 : 0) - .offset(y: appeared ? 0 : 12) - .animation(.spring(duration: 0.4, bounce: 0.15).delay(0.2), value: appeared) + .offset(y: appeared ? 0 : 20) + .animation(.spring(duration: 0.6, bounce: 0.1).delay(0.1), value: appeared) - Color.clear.frame(height: 40) + Color.clear.frame(height: 60) } - .padding(.horizontal, 16) - .padding(.top, 8) + .padding(20) } } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { - Text("einstellungen") - .font(.nightTitle(17)) - .foregroundColor(.nightPrimary) + Text("Einstellungen").font(.nightTitle(20)).foregroundColor(.nightPrimary) } - ToolbarItem(placement: .navigationBarLeading) { + ToolbarItem(placement: .navigationBarTrailing) { Button("Fertig") { dismiss() } - .foregroundColor(.nightSecondary) + .font(.nightLabel(16, weight: .bold)) + .foregroundColor(.nightPurpleSoft) } } .sheet(isPresented: $showLegal) { LegalView() } } .preferredColorScheme(.dark) - .confirmationDialog( - "Account wirklich löschen?", - isPresented: $showDeleteConfirm, - titleVisibility: .visible - ) { - Button("Ja, Account löschen", role: .destructive) { - showDeleteFinal = true - } + .confirmationDialog("Account löschen?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { + Button("Ja, endgültig 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.") + Text("Alle deine Daten werden unwiderruflich gelöscht.") } .sheet(isPresented: $showDeleteFinal) { - DeleteAccountSheet( - password: $deletePassword, - isDeleting: isDeleting, - error: deleteError, - onDelete: { Task { await deleteAccount() } } - ) + DeleteAccountSheet(password: $deletePassword, isDeleting: isDeleting, error: deleteError, onDelete: { Task { await deleteAccount() } }) } .onAppear { checkNotificationStatus() - appeared = true + withAnimation { appeared = true } } } - var appVersion: String { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" - } - + 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 @@ -223,66 +195,88 @@ struct SettingsView: View { try await appState.deleteAccount() showDeleteFinal = false dismiss() - } catch { - deleteError = error.localizedDescription + } catch { deleteError = error.localizedDescription } + } +} + +// MARK: - Refined Settings Components + +struct SettingsSection: View { + var header: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(header.uppercased()) + .font(.nightLabel(12, weight: .bold)) + .foregroundColor(.nightSecondary) + .tracking(1) + .padding(.leading, 8) + + VStack(spacing: 0) { content } + .background(RoundedRectangle(cornerRadius: 24, style: .continuous).fill(.ultraThinMaterial.opacity(0.4))) + .overlay(RoundedRectangle(cornerRadius: 24, style: .continuous).stroke(Color.white.opacity(0.05), lineWidth: 0.5)) } } } -// MARK: - Settings Components - -struct SettingsSection: View { - var header: String? = nil - @ViewBuilder let content: Content - +struct SettingsToggleRow: View { + let icon: String + let color: Color + let title: String + @Binding var isOn: Bool + var body: some View { - VStack(alignment: .leading, spacing: 0) { - if let header { - Text(header) - .font(.nightLabel(12, weight: .semibold)) - .foregroundColor(.nightSecondary) - .kerning(0.5) - .padding(.leading, 4) - .padding(.bottom, 8) + SettingsRow { + HStack { + SettingsIcon(icon: icon, color: color) + Text(title).font(.nightLabel(15)).foregroundColor(.nightPrimary) + Spacer() + Toggle("", isOn: $isOn).tint(.nightPurpleSoft).labelsHidden() } - VStack(spacing: 0) { - content + } + } +} + +struct SettingsActionRow: View { + let icon: String + let color: Color + let title: String + var showChevron: Bool = false + + var body: some View { + HStack { + SettingsIcon(icon: icon, color: color) + Text(title).font(.nightLabel(15)).foregroundColor(color == .nightRed ? .nightRed : .nightPrimary) + Spacer() + if showChevron { + Image(systemName: "chevron.right").font(.system(size: 12, weight: .bold)).foregroundColor(.nightTertiary) } - .background(Color.nightSurface) - .clipShape(RoundedRectangle(cornerRadius: 14)) - .overlay( - RoundedRectangle(cornerRadius: 14) - .strokeBorder(Color.nightBorder, lineWidth: 1) - ) } } } struct SettingsRow: View { @ViewBuilder let content: Content - var body: some View { - content - .padding(.horizontal, 16) - .padding(.vertical, 13) + content.padding(.horizontal, 16).padding(.vertical, 14) } } struct SettingsIcon: View { let icon: String let color: Color - var body: some View { Image(systemName: icon) - .font(.system(size: 13, weight: .medium)) + .font(.system(size: 13, weight: .bold)) .foregroundColor(color) - .frame(width: 28, height: 28) + .frame(width: 32, height: 32) .background(color.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 7)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) } } -// MARK: - Delete Account Sheet +// MARK: - Delete Account Sheet (Redesigned) struct DeleteAccountSheet: View { @Binding var password: String @@ -294,24 +288,21 @@ struct DeleteAccountSheet: View { 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) + EtherealBackground().ignoresSafeArea() + VStack(spacing: 32) { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 50)) + .foregroundColor(.nightRed) + + VStack(spacing: 8) { + Text("Account löschen").font(.nightTitle(24)).foregroundColor(.nightPrimary) + Text("Bestätige dein Passwort, um alle deine Daten dauerhaft zu löschen.").font(.nightBody(16)).foregroundColor(.nightSecondary).multilineTextAlignment(.center) + } } + .padding(.top, 40) - NightlyField(text: $password, placeholder: "passwort bestätigen", icon: "lock", isSecure: true) - .padding(.horizontal, 24) + NightlyField(text: $password, placeholder: "Dein Passwort", icon: "lock", isSecure: true) if let err = error { Text(err).font(.nightLabel(13)).foregroundColor(.nightRed) @@ -321,18 +312,16 @@ struct DeleteAccountSheet: View { Haptics.warning() onDelete() } label: { - Group { - if isDeleting { ProgressView().tint(.white) } - else { Text("endgültig löschen").font(.nightLabel(16, weight: .semibold)).foregroundColor(.white) } - } - .frame(maxWidth: .infinity).frame(height: 50) - .background(Color.nightRed) - .clipShape(RoundedRectangle(cornerRadius: 14)) + if isDeleting { ProgressView().tint(.white) } + else { Text("Endgültig löschen").font(.nightLabel(17, weight: .bold)) } } + .buttonStyle(NightlyPrimaryButton()) + .tint(.nightRed) .disabled(password.isEmpty || isDeleting) - .padding(.horizontal, 24) + + Spacer() } - .padding(.top, 32) + .padding(28) } .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -341,6 +330,5 @@ struct DeleteAccountSheet: View { } } .preferredColorScheme(.dark) - .presentationDetents([.medium]) } } diff --git a/ios/thoughts/thoughts/Views/WhisperListView.swift b/ios/thoughts/thoughts/Views/WhisperListView.swift new file mode 100644 index 0000000..d2289d7 --- /dev/null +++ b/ios/thoughts/thoughts/Views/WhisperListView.swift @@ -0,0 +1,159 @@ +import Combine +import SwiftUI + +struct WhisperListView: View { + @StateObject private var viewModel = WhisperListViewModel() + @Environment(\.dismiss) var dismiss + + var body: some View { + NavigationStack { + ZStack { + EtherealBackground().ignoresSafeArea() + + if viewModel.whispers.isEmpty && !viewModel.isLoading { + emptyView + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(viewModel.whispers) { whisper in + WhisperRow(whisper: whisper) { + viewModel.markRead(whisper) + } + } + } + .padding(16) + Color.clear.frame(height: 40) + } + .refreshable { await viewModel.load() } + } + } + .navigationTitle("Flüstern") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Fertig") { dismiss() } + .font(.nightLabel(16, weight: .bold)) + .foregroundColor(.nightPurpleSoft) + } + } + } + .preferredColorScheme(.dark) + .task { await viewModel.load() } + } + + var emptyView: some View { + VStack(spacing: 24) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 48)) + .foregroundColor(.nightPurple.opacity(0.25)) + VStack(spacing: 8) { + Text("Noch keine Flüsternachrichten") + .font(.nightTitle(19)) + .foregroundColor(.nightPrimary) + Text("Wenn dir jemand zuflüstert,\nlandet es hier.") + .font(.nightBody(15)) + .foregroundColor(.nightSecondary) + .multilineTextAlignment(.center) + .lineSpacing(4) + } + } + .padding(40) + } +} + +struct WhisperRow: View { + let whisper: Whisper + let onMarkRead: () -> Void + @State private var isRevealed = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Image(systemName: "bubble.left.fill") + .font(.system(size: 13)) + .foregroundColor(whisper.readAt == nil ? .nightPurpleSoft : .nightTertiary) + + Text("anonymes Flüstern") + .font(.nightLabel(12, weight: .semibold)) + .foregroundColor(whisper.readAt == nil ? .nightPrimary : .nightSecondary) + + Spacer() + + Text(timeAgo) + .font(.nightLabel(11)) + .foregroundColor(.nightTertiary) + + if whisper.readAt == nil { + Circle() + .fill(Color.nightPurple) + .frame(width: 6, height: 6) + } + } + + if isRevealed { + Text(whisper.content) + .font(.nightBody(15)) + .foregroundColor(.nightPrimary.opacity(0.9)) + .lineSpacing(4) + } else { + Button { + withAnimation(.spring(duration: 0.3)) { isRevealed = true } + Haptics.light() + onMarkRead() + } label: { + HStack(spacing: 6) { + Image(systemName: "eye.fill") + .font(.system(size: 11)) + Text("Tippe um zu lesen") + .font(.nightLabel(13)) + } + .foregroundColor(.nightPurpleSoft) + .padding(.vertical, 6) + } + } + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 16, style: .continuous).fill(.ultraThinMaterial.opacity(0.35))) + .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(whisper.readAt == nil ? Color.nightPurple.opacity(0.15) : Color.white.opacity(0.05), lineWidth: 0.5)) + } + + var timeAgo: String { + let diff = Date().timeIntervalSince(whisper.createdAt) + if diff < 60 { return "jetzt" } + if diff < 3600 { return "\(Int(diff / 60))m" } + return "\(Int(diff / 3600))h" + } +} + +@MainActor +class WhisperListViewModel: ObservableObject { + @Published var whispers: [Whisper] = [] + @Published var isLoading = false + + func load() async { + isLoading = true + defer { isLoading = false } + do { + whispers = try await supabase.getMyWhispers() + } catch { + whispers = [] + } + } + + func markRead(_ whisper: Whisper) { + if whisper.readAt == nil { + Task { + try? await supabase.markWhisperRead(id: whisper.id) + if let idx = whispers.firstIndex(where: { $0.id == whisper.id }) { + whispers[idx] = Whisper( + id: whisper.id, + fromUserId: whisper.fromUserId, + content: whisper.content, + readAt: Date(), + createdAt: whisper.createdAt + ) + } + } + } + } +} \ No newline at end of file diff --git a/ios/thoughts/thoughtsTests/IntegrationTests.swift b/ios/thoughts/thoughtsTests/IntegrationTests.swift new file mode 100644 index 0000000..2e2feb0 --- /dev/null +++ b/ios/thoughts/thoughtsTests/IntegrationTests.swift @@ -0,0 +1,79 @@ +import Testing +import Foundation +import Supabase +import Auth +@testable import thoughts + +@MainActor +struct IntegrationTests { + + private func loginDevUser() async throws -> AppState { + let state = AppState() + + // Debug-Bypass für Tests aktivieren (damit 2-5 Uhr Regel ignoriert wird) + AppSettings.shared.bypassWindowLogic = true + state.updateWindowState() + + try? await supabase.auth.signOut() + + do { + try await state.signIn(email: DevCredentials.email, password: DevCredentials.password) + } catch { + print("[TEST] User existiert nicht, registriere neu...") + try? await state.signUp( + email: DevCredentials.email, + password: DevCredentials.password, + username: "devuser", + displayName: "Dev User" + ) + // Dem Trigger Zeit geben (Profile anlegen) + try await Task.sleep(nanoseconds: 6 * 1_000_000_000) + try await state.signIn(email: DevCredentials.email, password: DevCredentials.password) + } + + let session = try? await supabase.auth.session + print("[TEST] Login erfolgreich. UID: \(session?.user.id.uuidString ?? "nil")") + return state + } + + @Test func testAuthentication() async throws { + let state = try await loginDevUser() + #expect(state.isAuthenticated == true) + + // Versuche Profil mehrmals zu laden falls Trigger langsam + if state.currentUser == nil { + print("[TEST] Profil nicht da, warte auf Trigger...") + try await Task.sleep(nanoseconds: 3 * 1_000_000_000) + try await state.signIn(email: DevCredentials.email, password: DevCredentials.password) + } + + #expect(state.currentUser != nil, "Profil fehlt in 'public.profiles'. Trigger on_auth_user_created prüfen!") + } + + @Test func testPostCreationAndFetch() async throws { + let state = try await loginDevUser() + let content = "Test_Post_\(Int.random(in: 1000...9999))" + + // Posten (Gibt jetzt die ID zurück) + let postId = try await supabase.createPost(content: content, mood: .still, isAnonymous: true) + print("[TEST] Post gesendet (ID: \(postId)): \(content)") + + // Kurz warten bis View aktualisiert + try await Task.sleep(nanoseconds: 2 * 1_000_000_000) + + // Feed laden + let posts = try await supabase.getFeed() + let found = posts.contains { $0.content == content } + + if !found { + print("[TEST] Post nicht im Feed gefunden. Feed Count: \(posts.count)") + for p in posts.prefix(5) { print(" - Post: \(p.content)") } + } + + #expect(found == true, "Post wurde erstellt aber taucht nicht im Feed auf. RLS Policy 'posts_select' prüfen!") + + // Cleanup (Löscht den Testpost sofort wieder) + try? await supabase.hardDeletePost(id: postId) + print("[TEST] Cleanup abgeschlossen.") + } +} diff --git a/opencode.jsonc b/opencode.jsonc new file mode 100644 index 0000000..3cd4967 --- /dev/null +++ b/opencode.jsonc @@ -0,0 +1,10 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "xcode": { + "type": "local", + "command": ["npx", "-y", "xpresso-mcp"], + "enabled": true + } + } +} \ No newline at end of file