Compare commits

..

3 Commits

Author SHA1 Message Date
denshooter e4f99255bd WIP: lokalen Stand vor dem Aufräumen sichern
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:32:22 +02:00
denshooter 7e910ce23d Allow anonymous posts in public feed
- update posts_select RLS to allow approved/ok posts regardless of anonymity
- sync baseline and perf-fix migrations with live policy

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 12:38:54 +00:00
denshooter 0d4b69713d Fix Supabase linter performance findings
- add covering indexes for flagged foreign keys
- optimize RLS policies to use select auth.uid()
- consolidate posts update policies into one permissive rule
- document linter findings and decisions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 12:00:43 +00:00
71 changed files with 3649 additions and 5362 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"mcpServers": {
"xcode-tools": {
"command": "xcrun",
"args": [
"mcpbridge"
]
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"xcode": {
"command": "npx",
"args": ["-y", "xcode-mcp-server"]
}
}
}
+64
View File
@@ -0,0 +1,64 @@
# Database performance notes (Supabase linter)
This project had two performance-related linter groups:
1. **RLS auth function evaluation**
2. **Unindexed foreign keys**
## What was changed
1. Added migration `infrastructure/migrations/002_perf_linter_fixes.sql`.
2. Updated `infrastructure/migrations/001_schema.sql` to keep fresh setups aligned with the same fixes.
### RLS changes
The affected policies now use:
- `(select auth.uid())` instead of `auth.uid()`
This avoids repeated per-row evaluation in RLS plans for these policies:
- `profiles_update`
- `posts_select`
- `posts_insert`
- `posts_update_own`
- `resonances_insert`
- `resonances_delete`
- `whispers_select`
- `whispers_insert`
- `follows_insert`
- `follows_delete`
- `reports_insert`
- `reports_select_own`
Also, duplicate permissive update policies on `public.posts` were consolidated by removing `posts_softdelete` and keeping a single `posts_update_own`.
### FK index changes
Added covering indexes:
- `idx_follows_following_id` on `public.follows(following_id)`
- `idx_reports_reporter_id` on `public.reports(reporter_id)`
- `idx_resonances_user_id` on `public.resonances(user_id)`
- `idx_whispers_from_user_id` on `public.whispers(from_user_id)`
- `idx_whispers_post_id` on `public.whispers(post_id)`
## About "unused index" findings
`unused_index` is based on `pg_stat_user_indexes.idx_scan`, which can read as zero after restarts or during low traffic.
In this environment, PostgreSQL was recently restarted, so these were not dropped automatically.
Suggested review query:
```sql
select
schemaname,
relname as table_name,
indexrelname as index_name,
idx_scan
from pg_stat_user_indexes
where schemaname = 'public'
order by idx_scan asc, indexrelname;
```
Drop an index only after observing sustained zero usage over representative production traffic.
+17 -19
View File
@@ -87,8 +87,13 @@ CREATE INDEX IF NOT EXISTS idx_posts_created_at ON public.posts(created_at DESC)
CREATE INDEX IF NOT EXISTS idx_posts_review ON public.posts(review_status) CREATE INDEX IF NOT EXISTS idx_posts_review ON public.posts(review_status)
WHERE review_status != 'ok'; WHERE review_status != 'ok';
CREATE INDEX IF NOT EXISTS idx_resonances_post ON public.resonances(post_id); CREATE INDEX IF NOT EXISTS idx_resonances_post ON public.resonances(post_id);
CREATE INDEX IF NOT EXISTS idx_resonances_user_id ON public.resonances(user_id);
CREATE INDEX IF NOT EXISTS idx_follows_follower ON public.follows(follower_id); CREATE INDEX IF NOT EXISTS idx_follows_follower ON public.follows(follower_id);
CREATE INDEX IF NOT EXISTS idx_follows_following_id ON public.follows(following_id);
CREATE INDEX IF NOT EXISTS idx_whispers_to ON public.whispers(to_user_id, read_at); CREATE INDEX IF NOT EXISTS idx_whispers_to ON public.whispers(to_user_id, read_at);
CREATE INDEX IF NOT EXISTS idx_whispers_from_user_id ON public.whispers(from_user_id);
CREATE INDEX IF NOT EXISTS idx_whispers_post_id ON public.whispers(post_id);
CREATE INDEX IF NOT EXISTS idx_reports_reporter_id ON public.reports(reporter_id);
-- ── View: Feed Posts ──────────────────────────────────────────────────────── -- ── View: Feed Posts ────────────────────────────────────────────────────────
-- Wird in der iOS-App direkt abgefragt — joins alles zusammen -- Wird in der iOS-App direkt abgefragt — joins alles zusammen
@@ -195,43 +200,36 @@ ALTER TABLE public.reports ENABLE ROW LEVEL SECURITY;
-- profiles: jeder darf lesen, nur eigenes darf geändert werden -- profiles: jeder darf lesen, nur eigenes darf geändert werden
CREATE POLICY "profiles_select" ON public.profiles FOR SELECT USING (true); CREATE POLICY "profiles_select" ON public.profiles FOR SELECT USING (true);
CREATE POLICY "profiles_update" ON public.profiles FOR UPDATE USING (auth.uid() = id); CREATE POLICY "profiles_update" ON public.profiles FOR UPDATE USING ((select auth.uid()) = id);
-- posts: Feed-Sichtbarkeit (über View geregelt), eigene Verwaltung -- posts: Feed-Sichtbarkeit (über View geregelt), eigene Verwaltung
CREATE POLICY "posts_select" ON public.posts FOR SELECT CREATE POLICY "posts_select" ON public.posts FOR SELECT
USING ( USING (
deleted_at IS NULL deleted_at IS NULL
AND review_status IN ('ok', 'approved') AND review_status IN ('ok', 'approved')
AND (
NOT is_anonymous
OR user_id = auth.uid()
)
); );
CREATE POLICY "posts_insert" ON public.posts FOR INSERT CREATE POLICY "posts_insert" ON public.posts FOR INSERT
WITH CHECK (auth.uid() = user_id); WITH CHECK ((select auth.uid()) = user_id);
CREATE POLICY "posts_update_own" ON public.posts FOR UPDATE CREATE POLICY "posts_update_own" ON public.posts FOR UPDATE
USING (auth.uid() = user_id AND deleted_at IS NULL); USING ((select auth.uid()) = user_id)
-- Soft-Delete: nur eigene Posts dürfen als gelöscht markiert werden WITH CHECK ((select auth.uid()) = user_id);
CREATE POLICY "posts_softdelete" ON public.posts FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (deleted_at IS NOT NULL);
-- resonances -- resonances
CREATE POLICY "resonances_select" ON public.resonances FOR SELECT USING (true); CREATE POLICY "resonances_select" ON public.resonances FOR SELECT USING (true);
CREATE POLICY "resonances_insert" ON public.resonances FOR INSERT WITH CHECK (auth.uid() = user_id); CREATE POLICY "resonances_insert" ON public.resonances FOR INSERT WITH CHECK ((select auth.uid()) = user_id);
CREATE POLICY "resonances_delete" ON public.resonances FOR DELETE USING (auth.uid() = user_id); CREATE POLICY "resonances_delete" ON public.resonances FOR DELETE USING ((select auth.uid()) = user_id);
-- whispers: nur Absender und Empfänger -- whispers: nur Absender und Empfänger
CREATE POLICY "whispers_select" ON public.whispers FOR SELECT CREATE POLICY "whispers_select" ON public.whispers FOR SELECT
USING (auth.uid() = from_user_id OR auth.uid() = to_user_id); USING ((select auth.uid()) = from_user_id OR (select auth.uid()) = to_user_id);
CREATE POLICY "whispers_insert" ON public.whispers FOR INSERT CREATE POLICY "whispers_insert" ON public.whispers FOR INSERT
WITH CHECK (auth.uid() = from_user_id); WITH CHECK ((select auth.uid()) = from_user_id);
-- follows -- follows
CREATE POLICY "follows_select" ON public.follows FOR SELECT USING (true); CREATE POLICY "follows_select" ON public.follows FOR SELECT USING (true);
CREATE POLICY "follows_insert" ON public.follows FOR INSERT WITH CHECK (auth.uid() = follower_id); CREATE POLICY "follows_insert" ON public.follows FOR INSERT WITH CHECK ((select auth.uid()) = follower_id);
CREATE POLICY "follows_delete" ON public.follows FOR DELETE USING (auth.uid() = follower_id); CREATE POLICY "follows_delete" ON public.follows FOR DELETE USING ((select auth.uid()) = follower_id);
-- reports: nur eigene einsehen -- reports: nur eigene einsehen
CREATE POLICY "reports_insert" ON public.reports FOR INSERT WITH CHECK (auth.uid() = reporter_id); CREATE POLICY "reports_insert" ON public.reports FOR INSERT WITH CHECK ((select auth.uid()) = reporter_id);
CREATE POLICY "reports_select_own" ON public.reports FOR SELECT USING (auth.uid() = reporter_id); CREATE POLICY "reports_select_own" ON public.reports FOR SELECT USING ((select auth.uid()) = reporter_id);
@@ -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')
);
@@ -0,0 +1,58 @@
-- Performance follow-up for DB linter findings:
-- 1) Wrap auth.uid() in RLS policies with SELECT to avoid per-row re-evaluation.
-- 2) Ensure every FK in follows/reports/resonances/whispers has a covering index.
-- 3) Merge duplicate permissive UPDATE policies on posts.
BEGIN;
ALTER POLICY profiles_update ON public.profiles
USING ((select auth.uid()) = id);
ALTER POLICY posts_select ON public.posts
USING (
deleted_at IS NULL
AND review_status IN ('ok', 'approved')
);
ALTER POLICY posts_insert ON public.posts
WITH CHECK ((select auth.uid()) = user_id);
DROP POLICY IF EXISTS posts_softdelete ON public.posts;
ALTER POLICY posts_update_own ON public.posts
USING ((select auth.uid()) = user_id)
WITH CHECK ((select auth.uid()) = user_id);
ALTER POLICY resonances_insert ON public.resonances
WITH CHECK ((select auth.uid()) = user_id);
ALTER POLICY resonances_delete ON public.resonances
USING ((select auth.uid()) = user_id);
ALTER POLICY whispers_select ON public.whispers
USING (
(select auth.uid()) = from_user_id
OR (select auth.uid()) = to_user_id
);
ALTER POLICY whispers_insert ON public.whispers
WITH CHECK ((select auth.uid()) = from_user_id);
ALTER POLICY follows_insert ON public.follows
WITH CHECK ((select auth.uid()) = follower_id);
ALTER POLICY follows_delete ON public.follows
USING ((select auth.uid()) = follower_id);
ALTER POLICY reports_insert ON public.reports
WITH CHECK ((select auth.uid()) = reporter_id);
ALTER POLICY reports_select_own ON public.reports
USING ((select auth.uid()) = reporter_id);
CREATE INDEX IF NOT EXISTS idx_follows_following_id ON public.follows (following_id);
CREATE INDEX IF NOT EXISTS idx_reports_reporter_id ON public.reports (reporter_id);
CREATE INDEX IF NOT EXISTS idx_resonances_user_id ON public.resonances (user_id);
CREATE INDEX IF NOT EXISTS idx_whispers_from_user_id ON public.whispers (from_user_id);
CREATE INDEX IF NOT EXISTS idx_whispers_post_id ON public.whispers (post_id);
COMMIT;
+112
View File
@@ -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;
+10
View File
@@ -0,0 +1,10 @@
{
"mcpServers": {
"xcode-tools": {
"command": "xcrun",
"args": [
"mcpbridge"
]
}
}
}
-124
View File
@@ -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() }
}
}
}
-79
View File
@@ -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)
}
}
-107
View File
@@ -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()
)
]
}
-25
View File
@@ -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
)
}
-67
View File
@@ -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")
}
-35
View File
@@ -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
}()
}
-127
View File
@@ -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<T: Decodable>(_ path: String) async throws -> T {
try await perform(makeRequest("GET", path: path))
}
@discardableResult
private func post<T: Decodable>(_ path: String, body: [String: Any]) async throws -> T {
var req = makeRequest("POST", path: path)
req.httpBody = try JSONSerialization.data(withJSONObject: body)
return try await perform(req)
}
@discardableResult
private func delete<T: Decodable>(_ path: String) async throws -> T {
try await perform(makeRequest("DELETE", path: path))
}
private func makeRequest(_ method: String, path: String) -> URLRequest {
var req = URLRequest(url: baseURL.appendingPathComponent(path))
req.httpMethod = method
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let t = authToken { req.setValue("Bearer \(t)", forHTTPHeaderField: "Authorization") }
return req
}
private func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
guard (200...299).contains(http.statusCode) else {
let msg = (try? JSONDecoder().decode(APIErrorBody.self, from: data))?.message
?? HTTPURLResponse.localizedString(forStatusCode: http.statusCode)
throw APIError.serverError(msg)
}
let dec = JSONDecoder()
dec.dateDecodingStrategy = .iso8601
return try dec.decode(T.self, from: data)
}
}
private struct AuthResponse: Decodable { let token: String }
private struct StreakResponse: Decodable { let streak: Int }
private struct APIErrorBody: Decodable { let message: String }
struct EmptyResponse: Decodable {}
enum APIError: LocalizedError {
case invalidResponse
case serverError(String)
var errorDescription: String? {
switch self {
case .invalidResponse: return "Ungültige Serverantwort"
case .serverError(let m): return m
}
}
}
@@ -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)
}
}
@@ -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()
}
}
@@ -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
)
}
@@ -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
}
@@ -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)
}
}
@@ -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
}
}
}
-320
View File
@@ -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"
}
}
-287
View File
@@ -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()
}
}
-385
View File
@@ -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)
}
}
-232
View File
@@ -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<Content: View>: View {
let title: String
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text(title)
.font(.nightTitle(22))
.foregroundColor(.nightPrimary)
.padding(.bottom, 4)
content
}
}
}
struct LegalParagraph: View {
let title: String
let body: String
init(title: String, _ body: () -> String) {
self.title = title
self.body = body()
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.nightLabel(14, weight: .semibold))
.foregroundColor(.nightPrimary)
Text(body)
.font(.nightBody(14))
.foregroundColor(.nightSecondary)
.lineSpacing(4)
.fixedSize(horizontal: false, vertical: true)
}
}
}
-199
View File
@@ -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"
}
}
}
@@ -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 }
}
}
}
-291
View File
@@ -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
}
}
}
-214
View File
@@ -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)
}
}
-16
View File
@@ -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)
}
}
-243
View File
@@ -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])
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"mcpServers": {
"xcode-tools": {
"command": "xcrun",
"args": [
"mcpbridge"
]
}
}
}
+42
View File
@@ -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
<!DOCTYPE html>
<html>
<body style="background-color: #020205; color: #EEEEF8; font-family: 'Courier New', Courier, monospace; padding: 40px;">
<div style="max-width: 400px; margin: 0 auto; border: 1px solid #3A3A52; padding: 30px;">
<h1 style="color: #7B4FE8; font-size: 24px; margin-bottom: 20px;">SYSTEM_RECOVERY</h1>
<p style="font-size: 14px; line-height: 1.6;">
Es wurde eine Anfrage zum Zurücksetzen deines Passworts für **thoughts** empfangen.
</p>
<div style="margin: 30px 0;">
<a href="{{ .ConfirmationURL }}"
style="background-color: #7B4FE8; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 2px; font-weight: bold; display: inline-block;">
PASSWORT_ZURÜCKSETZEN
</a>
</div>
<p style="font-size: 11px; color: #64647A;">
Dieser Link ist für 60 Minuten gültig.<br>
Falls du das nicht warst, ignoriere diese E-Mail einfach.
</p>
<hr style="border: 0; border-top: 1px solid #3A3A52; margin-top: 30px;">
<p style="font-size: 10px; color: #3A3A52; text-align: center;">
nightly.tech // encrypted_communication
</p>
</div>
</body>
</html>
```
### 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.
+43
View File
@@ -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;
$$;
@@ -301,6 +301,8 @@
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
95576B6B2F98D4200029BE54 /* Debug */ = { 95576B6B2F98D4200029BE54 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 95576B482F98D41F0029BE54 /* thoughts */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@@ -335,6 +337,8 @@
}; };
95576B6C2F98D4200029BE54 /* Release */ = { 95576B6C2F98D4200029BE54 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 95576B482F98D41F0029BE54 /* thoughts */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@@ -369,6 +373,8 @@
}; };
95576B6D2F98D4200029BE54 /* Debug */ = { 95576B6D2F98D4200029BE54 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 95576B482F98D41F0029BE54 /* thoughts */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -433,6 +439,8 @@
}; };
95576B6E2F98D4200029BE54 /* Release */ = { 95576B6E2F98D4200029BE54 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 95576B482F98D41F0029BE54 /* thoughts */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -490,6 +498,8 @@
}; };
95576B702F98D4200029BE54 /* Debug */ = { 95576B702F98D4200029BE54 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 95576B482F98D41F0029BE54 /* thoughts */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
@@ -512,6 +522,8 @@
}; };
95576B712F98D4200029BE54 /* Release */ = { 95576B712F98D4200029BE54 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 95576B482F98D41F0029BE54 /* thoughts */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
@@ -534,6 +546,8 @@
}; };
95576B732F98D4200029BE54 /* Debug */ = { 95576B732F98D4200029BE54 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 95576B482F98D41F0029BE54 /* thoughts */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
@@ -554,6 +568,8 @@
}; };
95576B742F98D4200029BE54 /* Release */ = { 95576B742F98D4200029BE54 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 95576B482F98D41F0029BE54 /* thoughts */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
+107 -21
View File
@@ -2,16 +2,67 @@ import Combine
import SwiftUI import SwiftUI
import Supabase 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 @MainActor
class AppState: ObservableObject { class AppState: ObservableObject {
@Published var isAuthenticated = false @Published var isAuthenticated = false
@Published var currentUser: User? @Published var currentUser: User?
@Published var windowState: WindowState = .closed @Published var windowState: WindowState = .closed
@Published var showResetPassword = false
private var windowTimer: Timer? private var windowTimer: Timer?
enum WindowState { case closed, open, posted, missed } 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() { init() {
Task { await checkSession() } Task { await checkSession() }
startWindowTimer() startWindowTimer()
@@ -34,6 +85,7 @@ class AppState: ObservableObject {
return return
} catch { } catch {
print("[DEBUG] Auto-Login fehlgeschlagen: \(error.localizedDescription)") print("[DEBUG] Auto-Login fehlgeschlagen: \(error.localizedDescription)")
print("[DEBUG] Stelle sicher, dass der Dev-Account (\(DevCredentials.email)) in Supabase existiert.")
} }
#endif #endif
isAuthenticated = false isAuthenticated = false
@@ -64,9 +116,9 @@ class AppState: ObservableObject {
func signOut() { func signOut() {
Task { Task {
try? await supabase.auth.signOut() try? await supabase.auth.signOut()
isAuthenticated = false
currentUser = nil
} }
isAuthenticated = false
currentUser = nil
} }
func deleteAccount() async throws { func deleteAccount() async throws {
@@ -75,24 +127,43 @@ class AppState: ObservableObject {
currentUser = nil 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 { private func loadProfile(userId: UUID) async {
guard let profile = try? await supabase.getMyProfile() else { return } do {
currentUser = User( let profile = try await supabase.getMyProfile()
id: profile.id.uuidString, currentUser = User(
username: profile.username, id: profile.id.uuidString,
displayName: profile.displayName, username: profile.username,
bio: profile.bio, displayName: profile.displayName,
avatarURL: profile.avatarUrl.flatMap(URL.init), bio: profile.bio,
followerCount: 0, avatarURL: profile.avatarUrl.flatMap(URL.init),
followingCount: 0, followerCount: 0,
postCount: 0, followingCount: 0,
isFollowing: false 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() { private func observeAuthChanges() {
Task { Task {
for await (event, session) in await supabase.auth.authStateChanges { for await (event, session) in supabase.auth.authStateChanges {
switch event { switch event {
case .signedIn: case .signedIn:
if let session { if let session {
@@ -112,13 +183,28 @@ class AppState: ObservableObject {
// MARK: - Window State // MARK: - Window State
func updateWindowState() { func updateWindowState() {
let hour = Calendar.current.component(.hour, from: Date()) if AppSettings.shared.bypassWindowLogic {
guard hour >= 2 && hour < 5 else { windowState = .closed; return } windowState = .open
return
}
let hasPosted = UserDefaults.standard.object(forKey: "lastPostDate") let inWindow = Self.isInNightWindow(at: Date(), bypass: false)
.flatMap { $0 as? Date } if !inWindow {
.map { Calendar.current.isDateInToday($0) } ?? false let lastPost = UserDefaults.standard.object(forKey: "lastPostDate") as? Date
windowState = hasPosted ? .posted : .open 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() { func markAsPosted() {
@@ -1,6 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "icon_1024.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
@@ -12,6 +13,7 @@
"value" : "dark" "value" : "dark"
} }
], ],
"filename" : "icon_1024.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
@@ -23,6 +25,7 @@
"value" : "tinted" "value" : "tinted"
} }
], ],
"filename" : "icon_1024.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
Binary file not shown.

After

Width:  |  Height:  |  Size: 933 KiB

+48 -24
View File
@@ -3,22 +3,22 @@ import SwiftUI
// MARK: - Design Tokens // MARK: - Design Tokens
extension Color { extension Color {
// Backgrounds — kein reines Schwarz, sondern Mitternachtsblau // Backgrounds — deeper, softer tones
static let nightBase = Color(hex: "080810") // Haupt-Hintergrund static let nightBase = Color(hex: "030308") // Haupt-Hintergrund
static let nightSurface = Color(hex: "0E0E1C") // Karten, Sheets static let nightSurface = Color(hex: "0A0A15") // Karten, Sheets
static let nightRaised = Color(hex: "151528") // Elevated surfaces static let nightRaised = Color(hex: "121225") // Elevated surfaces
static let nightBorder = Color(white: 1, opacity: 0.06) static let nightBorder = Color(white: 1, opacity: 0.1)
// Text // Text
static let nightPrimary = Color(hex: "EEEEF8") static let nightPrimary = Color(hex: "F8F8FF")
static let nightSecondary = Color(hex: "64647A") static let nightSecondary = Color(hex: "A0A0B8")
static let nightTertiary = Color(hex: "3A3A52") static let nightTertiary = Color(hex: "5A5A75")
// Akzente // Akzente — softer ethereal pastel hues
static let nightPurple = Color(hex: "7B4FE8") static let nightPurple = Color(hex: "8E74FF")
static let nightPurpleSoft = Color(hex: "9B77F0") static let nightPurpleSoft = Color(hex: "B4A5FF")
static let nightGreen = Color(hex: "34D399") static let nightGreen = Color(hex: "5EEAD4")
static let nightRed = Color(hex: "F27474") static let nightRed = Color(hex: "FF6B6B")
// Hex initializer // Hex initializer
init(hex: String) { init(hex: String) {
@@ -44,10 +44,12 @@ extension Color {
extension Mood { extension Mood {
var color: Color { var color: Color {
switch self { switch self {
case .still: return Color(hex: "4A9EFF") case .still: return Color(hex: "818CF8")
case .unruhig: return Color(hex: "FF8C42") case .unruhig: return Color(hex: "FB923C")
case .melancholisch: return Color(hex: "A855F7") case .schwer: return Color(hex: "A78BFA")
case .aufgedreht: return Color(hex: "10D08A") case .geladen: return Color(hex: "2DD4BF")
case .zart: return Color(hex: "F9A8D4")
case .dunkel: return Color(hex: "9CA3AF")
} }
} }
var label: String { rawValue } var label: String { rawValue }
@@ -55,8 +57,30 @@ extension Mood {
switch self { switch self {
case .still: return "◌" case .still: return "◌"
case .unruhig: return "◎" case .unruhig: return "◎"
case .melancholisch: return "◑" case .schwer: return "◑"
case .aufgedreht: 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 { extension Font {
static func nightTitle(_ size: CGFloat) -> 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 { static func nightBody(_ size: CGFloat) -> Font {
.system(size: size, weight: .regular) .system(size: size, weight: .light, design: .default)
} }
static func nightMono(_ size: CGFloat) -> Font { static func nightSerif(_ size: CGFloat) -> Font {
.system(size: size, design: .monospaced) .system(size: size, design: .serif)
} }
static func nightLabel(_ size: CGFloat, weight: Font.Weight = .medium) -> Font { static func nightLabel(_ size: CGFloat, weight: Font.Weight = .medium) -> Font {
.system(size: size, weight: weight) .system(size: size, weight: weight, design: .rounded)
} }
} }
+49 -7
View File
@@ -8,23 +8,65 @@ enum Haptics {
private static let notification = UINotificationFeedbackGenerator() private static let notification = UINotificationFeedbackGenerator()
/// Leichtes Feedback — Resonance-Button, Follow /// 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 /// 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 /// 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 /// 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 /// 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 /// 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 /// 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)
}
} }
@@ -1,8 +1,5 @@
import SwiftUI import SwiftUI
// MARK: - Shimmer Modifier
/// Gleitender Lichteffekt über Skeleton-Elemente.
struct ShimmerModifier: ViewModifier { struct ShimmerModifier: ViewModifier {
@State private var phase: CGFloat = -1 @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 { struct SkeletonPostRow: View {
var body: some View { var body: some View {
HStack(alignment: .top, spacing: 0) { HStack(spacing: 0) {
// Mood-Accent-Bar
RoundedRectangle(cornerRadius: 1) RoundedRectangle(cornerRadius: 1)
.fill(Color.nightTertiary.opacity(0.2)) .fill(Color.nightTertiary.opacity(0.12))
.frame(width: 2) .frame(width: 2.5)
.padding(.vertical, 18) .padding(.vertical, 16)
VStack(alignment: .leading, spacing: 11) { VStack(alignment: .leading, spacing: 10) {
// Avatar + Name + Zeitstempel HStack(spacing: 8) {
HStack(spacing: 9) {
Circle() Circle()
.fill(Color.nightRaised) .fill(Color.nightRaised)
.frame(width: 32, height: 32) .frame(width: 28, height: 28)
RoundedRectangle(cornerRadius: 4) RoundedRectangle(cornerRadius: 4)
.fill(Color.nightRaised) .fill(Color.nightRaised)
.frame(width: 90, height: 13) .frame(width: 80, height: 12)
Spacer() Spacer()
RoundedRectangle(cornerRadius: 4) RoundedRectangle(cornerRadius: 4)
.fill(Color.nightRaised) .fill(Color.nightRaised)
.frame(width: 36, height: 11) .frame(width: 30, height: 10)
} }
// Content-Zeilen VStack(alignment: .leading, spacing: 5) {
VStack(alignment: .leading, spacing: 6) {
RoundedRectangle(cornerRadius: 4) RoundedRectangle(cornerRadius: 4)
.fill(Color.nightRaised) .fill(Color.nightRaised)
.frame(height: 13) .frame(height: 12)
RoundedRectangle(cornerRadius: 4) RoundedRectangle(cornerRadius: 4)
.fill(Color.nightRaised) .fill(Color.nightRaised)
.frame(width: 180, height: 13) .frame(width: 160, height: 12)
} }
// Resonance-Button Platzhalter
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
.fill(Color.nightRaised) .fill(Color.nightRaised)
.frame(width: 90, height: 24) .frame(width: 80, height: 22)
} }
.padding(.leading, 14) .padding(.leading, 14)
.padding(.trailing, 16) .padding(.trailing, 16)
@@ -90,18 +80,13 @@ struct SkeletonPostRow: View {
} }
} }
// MARK: - Skeleton Profile Header
/// Platzhalter für den Profilkopf beim Laden.
struct SkeletonProfileHeader: View { struct SkeletonProfileHeader: View {
var body: some View { var body: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
// Avatar
Circle() Circle()
.fill(Color.nightRaised) .fill(Color.nightRaised)
.frame(width: 76, height: 76) .frame(width: 76, height: 76)
// Name + Username
VStack(spacing: 8) { VStack(spacing: 8) {
RoundedRectangle(cornerRadius: 4) RoundedRectangle(cornerRadius: 4)
.fill(Color.nightRaised) .fill(Color.nightRaised)
@@ -111,7 +96,6 @@ struct SkeletonProfileHeader: View {
.frame(width: 80, height: 13) .frame(width: 80, height: 13)
} }
// Stats
HStack(spacing: 36) { HStack(spacing: 36) {
ForEach(0..<3, id: \.self) { _ in ForEach(0..<3, id: \.self) { _ in
VStack(spacing: 4) { VStack(spacing: 4) {
@@ -125,7 +109,6 @@ struct SkeletonProfileHeader: View {
} }
} }
// Action Button
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
.fill(Color.nightRaised) .fill(Color.nightRaised)
.frame(height: 40) .frame(height: 40)
+13
View File
@@ -2,6 +2,19 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>dk0.dev.thoughts</string>
<key>CFBundleURLSchemes</key>
<array>
<string>thoughts</string>
</array>
</dict>
</array>
<key>SUPABASE_URL</key> <key>SUPABASE_URL</key>
<string>$(SUPABASE_URL)</string> <string>$(SUPABASE_URL)</string>
<key>SUPABASE_ANON_KEY</key> <key>SUPABASE_ANON_KEY</key>
@@ -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() {}
}
+191 -18
View File
@@ -5,9 +5,11 @@ import Foundation
enum Mood: String, Codable, CaseIterable { enum Mood: String, Codable, CaseIterable {
case still = "still" case still = "still"
case unruhig = "unruhig" case unruhig = "unruhig"
case melancholisch = "melancholisch" case schwer = "schwer"
case aufgedreht = "aufgedreht" case geladen = "geladen"
// color, label, emoji → Colors.swift extension case zart = "zart"
case dunkel = "dunkel"
// color, label, emoji, description → Colors.swift extension
} }
// MARK: - Post // MARK: - Post
@@ -18,25 +20,42 @@ struct Post: Identifiable, Codable {
let content: String let content: String
let mood: Mood? let mood: Mood?
let createdAt: Date let createdAt: Date
var resonanceCount: Int // "Hat mich getroffen" var resonanceCount: Int
var hasResonated: Bool // Current user's reaction var hasResonated: Bool
var commentCount: Int var commentCount: Int
let isAnonymous: Bool let isAnonymous: Bool
let nightOf: Date 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 { var isExpired: Bool {
Date().timeIntervalSince(createdAt) > 14 * 3_600 Date().timeIntervalSince(createdAt) > 14 * 3_600
} }
// Is this post in the "Gerade Jetzt" window (< 10 min old)
var isRightNow: Bool { var isRightNow: Bool {
Date().timeIntervalSince(createdAt) < 10 * 60 Date().timeIntervalSince(createdAt) < 10 * 60
} }
var formattedTime: String { var formattedTime: String {
let f = DateFormatter() Self.dateFormatter.string(from: createdAt)
f.dateFormat = "HH:mm" }
return f.string(from: createdAt)
var formattedNightDate: String {
let nightDate = createdAt.addingTimeInterval(-2 * 3600)
return Self.nightDateFormatter.string(from: nightDate)
} }
var timeAgo: String { var timeAgo: String {
@@ -51,20 +70,23 @@ struct Post: Identifiable, Codable {
id: "1", id: "1",
author: .preview, author: .preview,
content: "warum denk ich um 3 uhr morgens noch an das was ich 2019 gesagt hab", content: "warum denk ich um 3 uhr morgens noch an das was ich 2019 gesagt hab",
mood: .melancholisch, mood: .schwer,
createdAt: Date().addingTimeInterval(-180), createdAt: Date().addingTimeInterval(-180),
resonanceCount: 12, resonanceCount: 12,
hasResonated: false, hasResonated: false,
commentCount: 3, commentCount: 3,
isAnonymous: false, isAnonymous: false,
nightOf: Date() nightOf: Date(),
hasContentWarning: false,
contentWarningType: nil
), ),
Post( Post(
id: "2", id: "2",
author: User( author: User(
id: "2", username: "insomniac_", displayName: "can't sleep", id: "2", username: "insomniac_", displayName: "can't sleep",
bio: nil, avatarURL: nil, 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", content: "das licht vom handy macht alles schlimmer aber ich leg es trotzdem nicht weg",
mood: .unruhig, mood: .unruhig,
@@ -73,35 +95,186 @@ struct Post: Identifiable, Codable {
hasResonated: true, hasResonated: true,
commentCount: 1, commentCount: 1,
isAnonymous: false, isAnonymous: false,
nightOf: Date() nightOf: Date(),
hasContentWarning: false,
contentWarningType: nil
), ),
Post( Post(
id: "3", id: "3",
author: .preview, author: .preview,
content: "ich warte irgendwie immer noch auf eine nachricht von dir obwohl ich weiß dass sie nicht kommt", 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), createdAt: Date().addingTimeInterval(-300),
resonanceCount: 31, resonanceCount: 31,
hasResonated: true, hasResonated: true,
commentCount: 7, commentCount: 7,
isAnonymous: true, isAnonymous: true,
nightOf: Date() nightOf: Date(),
hasContentWarning: false,
contentWarningType: nil
), ),
Post( Post(
id: "4", id: "4",
author: User( author: User(
id: "4", username: "felix.nacht", displayName: "Felix", id: "4", username: "felix.nacht", displayName: "Felix",
bio: nil, avatarURL: nil, 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", 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), createdAt: Date().addingTimeInterval(-60),
resonanceCount: 5, resonanceCount: 5,
hasResonated: false, hasResonated: false,
commentCount: 0, commentCount: 0,
isAnonymous: false, 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"
}
}
}
+5 -1
View File
@@ -10,6 +10,8 @@ struct User: Identifiable, Codable, Equatable {
var followingCount: Int var followingCount: Int
var postCount: Int var postCount: Int
var isFollowing: Bool var isFollowing: Bool
var isPro: Bool
var isAdmin: Bool
static let preview = User( static let preview = User(
id: "preview", id: "preview",
@@ -20,6 +22,8 @@ struct User: Identifiable, Codable, Equatable {
followerCount: 142, followerCount: 142,
followingCount: 89, followingCount: 89,
postCount: 37, postCount: 37,
isFollowing: false isFollowing: false,
isPro: true,
isAdmin: false
) )
} }
+3 -3
View File
@@ -14,8 +14,8 @@ import Foundation
#if DEBUG #if DEBUG
enum DevCredentials { enum DevCredentials {
static let email = "dev@nightly.test" static let email = "info@dk0.dev"
static let password = "TestPassword123!" static let password = "test123"
} }
#endif #endif
@@ -27,7 +27,7 @@ enum Config {
let url = URL(string: raw) let url = URL(string: raw)
else { else {
// Fallback für Entwicklung — ersetze mit deiner URL // 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 return url
}() }()
@@ -2,8 +2,6 @@ import Combine
import Foundation import Foundation
import Supabase import Supabase
/// Verwaltet die Echtzeit-Verbindung für "Gerade Jetzt".
/// Neue Posts erscheinen sofort ohne Polling.
@MainActor @MainActor
class RealtimeService: ObservableObject { class RealtimeService: ObservableObject {
@Published var newPostsCount = 0 @Published var newPostsCount = 0
@@ -15,19 +13,17 @@ class RealtimeService: ObservableObject {
self.onNewPost = onNewPost self.onNewPost = onNewPost
guard channel == nil else { return } 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 = ch.postgresChange(
let stream = await ch.postgresChange(
InsertAction.self, InsertAction.self,
schema: "public", schema: "public",
table: "posts" table: "posts"
) )
await ch.subscribe() try? await ch.subscribeWithError()
self.channel = ch self.channel = ch
// Stream im Hintergrund konsumieren
Task { [weak self] in Task { [weak self] in
for await action in stream { for await action in stream {
await self?.handleInsert(action) await self?.handleInsert(action)
@@ -36,29 +32,45 @@ class RealtimeService: ObservableObject {
} }
func stopListening() async { func stopListening() async {
if let ch = channel { guard let ch = channel else { return }
await supabase.removeChannel(ch) await supabase.removeChannel(ch)
channel = nil channel = nil
}
} }
private func handleInsert(_ action: InsertAction) { private func handleInsert(_ action: InsertAction) async {
// Den neuen Post aus dem Record dekodieren
guard guard
let id = action.record["id"]?.stringValue, let id = action.record["id"]?.stringValue,
let content = action.record["content"]?.stringValue, let content = action.record["content"]?.stringValue,
let createdAt = action.record["created_at"]?.stringValue let createdAt = action.record["created_at"]?.stringValue
.flatMap({ ISO8601DateFormatter().date(from: $0) }), .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 let isAnon = action.record["is_anonymous"]?.boolValue
else { return } else { return }
let moodString = action.record["mood"]?.stringValue let moodString = action.record["mood"]?.stringValue
let mood = moodString.flatMap(Mood.init(rawValue:)) 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( let post = Post(
id: id, id: id,
author: User.anonymousPlaceholder, // Profil wird lazily nachgeladen author: author,
content: content, content: content,
mood: mood, mood: mood,
createdAt: createdAt, createdAt: createdAt,
@@ -66,7 +78,9 @@ class RealtimeService: ObservableObject {
hasResonated: false, hasResonated: false,
commentCount: 0, commentCount: 0,
isAnonymous: isAnon, isAnonymous: isAnon,
nightOf: createdAt nightOf: createdAt,
hasContentWarning: hasCW,
contentWarningType: cwType
) )
newPostsCount += 1 newPostsCount += 1
@@ -74,14 +88,13 @@ class RealtimeService: ObservableObject {
} }
} }
// MARK: - User placeholder für Realtime (Profil wird nachgeladen)
extension User { extension User {
static let anonymousPlaceholder = User( static let anonymousPlaceholder = User(
id: "anonymous", id: "anonymous",
username: "anonym", username: "anonym",
displayName: "anonym", displayName: "anonym",
bio: nil, avatarURL: nil, bio: nil, avatarURL: nil,
followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false,
isPro: false, isAdmin: false
) )
} }
@@ -20,6 +20,9 @@ let supabase = SupabaseClient(
d.dateDecodingStrategy = .iso8601 d.dateDecodingStrategy = .iso8601
return d return d
}() }()
),
auth: .init(
emitLocalSessionAsInitialSession: true
) )
) )
) )
@@ -57,6 +60,14 @@ extension SupabaseClient {
try await self.auth.signOut() 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) /// Account vollständig löschen (DSGVO — löscht alles über DB-Funktion)
func deleteAccount() async throws { func deleteAccount() async throws {
try await self.rpc("delete_my_account").execute() try await self.rpc("delete_my_account").execute()
@@ -94,6 +105,66 @@ extension SupabaseClient {
.value .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 { func updateProfile(displayName: String? = nil, bio: String? = nil) async throws {
guard let uid = await currentUserId else { throw AuthError.notAuthenticated } guard let uid = await currentUserId else { throw AuthError.notAuthenticated }
var update: [String: String] = [:] var update: [String: String] = [:]
@@ -124,12 +195,39 @@ extension SupabaseClient {
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] { func getFeed() async throws -> [Post] {
guard let uid = await currentUserId else { return [] } guard let uid = await currentUserId else { return [] }
// Erst die gefolgten User-IDs holen let fourteenHoursAgo = Date().addingTimeInterval(-14 * 3600)
// Supabase gibt Objekte zurück [{following_id:"uuid"}], kein [String] 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 } struct FollowRow: Decodable { let followingId: String }
let followRows: [FollowRow] = try await self let followRows: [FollowRow] = try await self
.from("follows") .from("follows")
@@ -140,16 +238,19 @@ extension SupabaseClient {
let allIds = followRows.map(\.followingId) + [uid.uuidString] 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 let rows: [FeedPostRow] = try await self
.from("feed_posts") .from("feed_posts")
.select() .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) .order("created_at", ascending: false)
.limit(150) .limit(100)
.execute() .execute()
.value .value
// Eigene Resonances holen (RLS filtert, SDK gibt nur eigene zurück)
let myResonances: [ResonanceRow] = (try? await self let myResonances: [ResonanceRow] = (try? await self
.from("resonances") .from("resonances")
.select("post_id") .select("post_id")
@@ -189,23 +290,48 @@ extension SupabaseClient {
return rows.map { $0.toPost(hasResonated: false) } 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 } guard let uid = await currentUserId else { throw AuthError.notAuthenticated }
struct Params: Encodable { struct Params: Encodable {
let userId: String let userId: String
let content: String let content: String
let mood: String let mood: String
let isAnonymous: Bool 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) struct InsertResponse: Decodable { let id: String }
).execute()
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 { func softDeletePost(id: String) async throws {
guard let uid = await currentUserId else { throw AuthError.notAuthenticated }
try await self.from("posts") try await self.from("posts")
.update(["deleted_at": ISO8601DateFormatter().string(from: Date())]) .update(["deleted_at": ISO8601DateFormatter().string(from: Date())])
.eq("id", value: id) .eq("id", value: id)
.eq("user_id", value: uid)
.execute() .execute()
} }
} }
@@ -250,6 +376,26 @@ extension SupabaseClient {
.execute() .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 { func getStreak(userId: UUID) async throws -> Int {
// Nächte mit Posts — berechnet in SQL // Nächte mit Posts — berechnet in SQL
let rows: [[String: Int]] = (try? await self let rows: [[String: Int]] = (try? await self
@@ -338,25 +484,34 @@ struct FeedPostRow: Decodable {
let isAnonymous: Bool let isAnonymous: Bool
let createdAt: Date let createdAt: Date
let resonanceCount: Int let resonanceCount: Int
// Autor (nil bei anonymen Posts die nicht von mir sind)
let authorId: String? let authorId: String?
let authorUsername: String? let authorUsername: String?
let authorDisplayName: String? let authorDisplayName: String?
let authorAvatarUrl: String? let authorAvatarUrl: String?
let hasContentWarning: Bool?
let contentWarningType: String?
func toPost(hasResonated: Bool) -> Post { func toPost(hasResonated: Bool) -> Post {
let author: User? = authorId.map { let author: User
User( if isAnonymous {
id: $0, author = User.anonymousPlaceholder
username: authorUsername ?? "?", } else if let aid = authorId {
displayName: authorDisplayName ?? "?", author = User(
id: aid,
username: authorUsername ?? "user",
displayName: authorDisplayName ?? "User",
bio: nil, avatarURL: authorAvatarUrl.flatMap(URL.init), 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( return Post(
id: id, id: id,
author: author ?? User.anonymousPlaceholder, author: author,
content: content, content: content,
mood: mood.flatMap(Mood.init(rawValue:)), mood: mood.flatMap(Mood.init(rawValue:)),
createdAt: createdAt, createdAt: createdAt,
@@ -364,7 +519,9 @@ struct FeedPostRow: Decodable {
hasResonated: hasResonated, hasResonated: hasResonated,
commentCount: 0, commentCount: 0,
isAnonymous: isAnonymous, isAnonymous: isAnonymous,
nightOf: createdAt nightOf: createdAt,
hasContentWarning: hasContentWarning ?? false,
contentWarningType: contentWarningType.flatMap(ContentWarningType.init(rawValue:))
) )
} }
} }
@@ -5,13 +5,18 @@ import Foundation
class FeedViewModel: ObservableObject { class FeedViewModel: ObservableObject {
@Published var posts: [Post] = [] @Published var posts: [Post] = []
@Published var isLoading = false @Published var isLoading = false
@Published var errorMessage: String?
func load() async { func load() async {
isLoading = true if posts.isEmpty {
isLoading = true
}
defer { isLoading = false } defer { isLoading = false }
do { do {
posts = try await supabase.getFeed() posts = try await supabase.getFeedWithFollows()
errorMessage = nil
} catch { } catch {
errorMessage = "Feed konnte nicht geladen werden."
#if DEBUG #if DEBUG
posts = Post.previews posts = Post.previews
#endif #endif
@@ -33,9 +38,12 @@ class FeedViewModel: ObservableObject {
} }
} }
/// Neuen Post vom Realtime-Service in den Feed einfügen
func prepend(_ post: Post) { func prepend(_ post: Post) {
guard !posts.contains(where: { $0.id == post.id }) else { return } guard !posts.contains(where: { $0.id == post.id }) else { return }
posts.insert(post, at: 0) posts.insert(post, at: 0)
} }
func removePost(id: String) {
posts.removeAll { $0.id == id }
}
} }
@@ -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
}
}
}
@@ -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)
}
}
}
@@ -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))
}
}
@@ -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
}
}
}
@@ -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))
}
}
@@ -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()
}
}
}
@@ -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))
)
}
}
}
@@ -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()
}
}
+283 -216
View File
@@ -10,162 +10,48 @@ struct ComposeView: View {
@State private var isAnonymous = false @State private var isAnonymous = false
@State private var isPosting = false @State private var isPosting = false
@State private var errorMessage: String? @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 remaining: Int { maxChars - text.count }
var canPost: Bool { !text.trimmingCharacters(in: .whitespaces).isEmpty && selectedMood != nil } 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 { var body: some View {
NavigationStack { NavigationStack {
ZStack { ZStack {
Color.nightBase.ignoresSafeArea()
moodBackground.ignoresSafeArea() moodBackground.ignoresSafeArea()
.animation(.easeInOut(duration: 0.5), value: selectedMood)
Color.clear
.contentShape(Rectangle())
.onTapGesture { isFocused = false }
VStack(spacing: 0) { 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 { ScrollView {
HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 24) {
// Left: Avatar headerSection
VStack(spacing: 0) { .padding(.top, 20)
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 textInputSection
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 moodPickerSection
ZStack(alignment: .topLeading) { .padding(.top, 10)
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 if let err = errorMessage {
MoodPickerRow(selected: $selectedMood) Text(err)
.padding(.top, 4) .font(.nightLabel(13))
.foregroundColor(.nightRed)
Spacer().frame(height: 20) .padding(.top, 10)
} }
} }
.padding(.horizontal, 16) .padding(24)
.padding(.top, 18)
} }
Spacer() Spacer()
// Bottom bar: anonymous toggle + countdown bottomBar
Divider().background(Color.nightBorder)
HStack(spacing: 14) {
// Anonymous toggle
Button {
Haptics.select()
withAnimation(.spring(duration: 0.3, bounce: 0.4)) {
isAnonymous.toggle()
}
} label: {
HStack(spacing: 6) {
Image(systemName: isAnonymous ? "theatermasks.fill" : "theatermasks")
.font(.system(size: 15))
Text(isAnonymous ? "anonym" : "anonym posten")
.font(.nightLabel(13))
}
.foregroundColor(isAnonymous ? .nightPrimary : .nightSecondary)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(isAnonymous ? Color.nightRaised : .clear)
.clipShape(Capsule())
.overlay(
Capsule()
.strokeBorder(
isAnonymous ? Color.nightBorder : .clear,
lineWidth: 1
)
)
}
Spacer()
WindowCountdownView()
}
.padding(.horizontal, 20)
.padding(.vertical, 10)
.padding(.bottom, 8)
if let err = errorMessage {
Text(err)
.font(.nightLabel(13))
.foregroundColor(.nightRed)
.padding(.horizontal, 20)
.padding(.bottom, 8)
}
} }
} }
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -173,21 +59,203 @@ struct ComposeView: View {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
Button("Abbrechen") { dismiss() } Button("Abbrechen") { dismiss() }
.foregroundColor(.nightSecondary) .foregroundColor(.nightSecondary)
.font(.nightBody(16)) .font(.nightLabel(15))
}
ToolbarItem(placement: .navigationBarTrailing) {
PostButton(canPost: canPost, isPosting: isPosting) {
Task { await submit() }
}
} }
} }
.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) .preferredColorScheme(.dark)
} }
var currentTime: String { // MARK: - Mood Background
let f = DateFormatter(); f.dateFormat = "HH:mm"
return f.string(from: Date()) @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 { func submit() async {
@@ -196,10 +264,12 @@ struct ComposeView: View {
errorMessage = nil errorMessage = nil
defer { isPosting = false } defer { isPosting = false }
do { do {
try await supabase.createPost( _ = try await supabase.createPost(
content: text.trimmingCharacters(in: .whitespacesAndNewlines), content: text.trimmingCharacters(in: .whitespacesAndNewlines),
mood: mood, mood: mood,
isAnonymous: isAnonymous isAnonymous: isAnonymous,
hasContentWarning: contentWarningType != nil,
contentWarningType: contentWarningType
) )
Haptics.success() Haptics.success()
appState.markAsPosted() 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? @Binding var selected: Mood?
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { ScrollView(.horizontal, showsIndicators: false) {
Text("stimmung") HStack(spacing: 10) {
.font(.nightLabel(11, weight: .medium))
.foregroundColor(.nightTertiary)
.kerning(0.8)
HStack(spacing: 7) {
ForEach(Mood.allCases, id: \.self) { mood in ForEach(Mood.allCases, id: \.self) { mood in
MoodChip(mood: mood, isSelected: selected == mood) { MoodCard(mood: mood, isSelected: selected == mood) {
Haptics.soft() Haptics.soft()
withAnimation(.spring(duration: 0.3, bounce: 0.3)) { withAnimation(.spring(duration: 0.4, bounce: 0.4)) {
selected = selected == mood ? nil : mood selected = selected == mood ? nil : mood
} }
} }
@@ -237,89 +302,91 @@ struct MoodPickerRow: View {
} }
} }
struct MoodChip: View { struct MoodCard: View {
let mood: Mood let mood: Mood
let isSelected: Bool let isSelected: Bool
let action: () -> Void let action: () -> Void
var body: some View { var body: some View {
Button(action: action) { Button(action: action) {
HStack(spacing: 5) { VStack(spacing: 6) {
Text(mood.emoji) Text(mood.emoji)
.font(.nightMono(12)) .font(.system(size: isSelected ? 22 : 18))
.foregroundColor(isSelected ? mood.color : .nightSecondary) .animation(.spring(duration: 0.3), value: isSelected)
Text(mood.label)
.font(.nightLabel(12, weight: isSelected ? .semibold : .regular)) VStack(spacing: 2) {
.foregroundColor(isSelected ? .nightPrimary : .nightSecondary) Text(mood.label)
.font(.nightLabel(13, weight: isSelected ? .semibold : .medium))
Text(mood.description)
.font(.nightLabel(9))
.foregroundColor(.nightTertiary)
.lineLimit(1)
}
} }
.padding(.horizontal, 11) .padding(.horizontal, 14)
.padding(.vertical, 7) .padding(.vertical, 10)
.background( .background(
ZStack { RoundedRectangle(cornerRadius: 16, style: .continuous)
if isSelected { .fill(isSelected ? mood.color.opacity(0.15) : Color.white.opacity(0.04))
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)
}
}
) )
.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 // MARK: - Countdown (Smart)
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 { struct WindowCountdownView: View {
@State private var label = "" @State private var label = ""
var body: some View { var body: some View {
HStack(spacing: 5) { HStack(spacing: 6) {
Image(systemName: "clock") Image(systemName: "timer")
.font(.system(size: 11)) .font(.system(size: 12))
Text(label) Text(label)
.font(.nightMono(11)) .font(.nightLabel(12, weight: .bold))
} }
.foregroundColor(.nightPurple.opacity(0.5)) .foregroundColor(.nightTertiary)
.onAppear { tick() } .onAppear { tick() }
.onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in tick() } .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in tick() }
} }
func tick() { func tick() {
var c = Calendar.current.dateComponents([.year, .month, .day], from: Date()) let now = Date()
c.hour = 5; c.minute = 0; c.second = 0 let inWindow = AppState.isInNightWindow(at: now, bypass: AppSettings.shared.bypassWindowLogic)
guard let end = Calendar.current.date(from: c) else { return }
let diff = max(0, Int(end.timeIntervalSince(Date()))) if !inWindow {
label = diff > 0 let nextOpen = nextWindowOpenDate(from: now)
? String(format: "%d:%02d bis 05:00", diff / 60, diff % 60) let diff = max(0, Int(nextOpen.timeIntervalSince(now)))
: "fenster zu" 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)!
} }
} }
+169 -179
View File
@@ -1,8 +1,6 @@
import Combine import Combine
import SwiftUI import SwiftUI
/// Persönliches Tagebuch — alle eigenen Posts, auch die anonymen.
/// Bleibt für immer. Das ist der Retention-Hook.
struct DiaryView: View { struct DiaryView: View {
@StateObject private var viewModel = DiaryViewModel() @StateObject private var viewModel = DiaryViewModel()
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@@ -11,46 +9,46 @@ struct DiaryView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack { ZStack {
Color.nightBase.ignoresSafeArea() EtherealBackground().ignoresSafeArea()
if viewModel.groupedPosts.isEmpty && !viewModel.isLoading { if viewModel.groupedPosts.isEmpty && !viewModel.isLoading {
DiaryEmptyView() DiaryEmptyView()
} else { } else {
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 0) { LazyVStack(alignment: .leading, spacing: 20) {
// "Vor genau einem Jahr" — Memory
if let memory = viewModel.yearAgoPost { if let memory = viewModel.yearAgoPost {
MemoryBanner(post: memory) MemoryBanner(post: memory)
.padding(.bottom, 4) .padding(.horizontal, 16)
.padding(.top, 16)
} }
ForEach(Array(viewModel.groupedPosts.enumerated()), id: \.element.nightLabel) { groupIndex, group in ForEach(Array(viewModel.groupedPosts.enumerated()), id: \.element.nightLabel) { groupIndex, group in
// Nacht-Header VStack(alignment: .leading, spacing: 10) {
DiaryNightHeader(label: group.nightLabel, count: group.posts.count) Text(group.nightLabel.uppercased())
.opacity(postsAppeared ? 1 : 0) .font(.nightLabel(11, weight: .bold))
.animation( .foregroundColor(.nightPurpleSoft)
.spring(duration: 0.35, bounce: 0.1) .tracking(1)
.delay(Double(min(groupIndex, 6)) * 0.06), .padding(.horizontal, 20)
value: postsAppeared
)
ForEach(group.posts) { post in ForEach(group.posts) { post in
DiaryPostRow(post: post) { DiaryPostRow(post: post) {
Task { await viewModel.deletePost(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 } .onAppear { postsAppeared = true }
Color.clear.frame(height: 100) Color.clear.frame(height: 120)
} }
.padding(.top, 8)
} }
.refreshable { await viewModel.load() } .refreshable { await viewModel.load() }
} }
@@ -58,13 +56,9 @@ struct DiaryView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
HStack(spacing: 7) { Text("Tagebuch")
Image(systemName: "book.closed.fill") .font(.nightTitle(20))
.foregroundColor(.nightPurple) .foregroundColor(.nightPrimary)
Text("tagebuch")
.font(.nightTitle(17))
.foregroundColor(.nightPrimary)
}
} }
} }
} }
@@ -72,23 +66,20 @@ struct DiaryView: View {
} }
} }
// MARK: - Memory Banner
struct MemoryBanner: View { struct MemoryBanner: View {
let post: Post let post: Post
@State private var show = true @State private var show = true
var body: some View { var body: some View {
if show { if show {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "clock.arrow.circlepath") Image(systemName: "sparkles")
.font(.system(size: 13))
.foregroundColor(.nightPurpleSoft) .foregroundColor(.nightPurpleSoft)
.font(.system(size: 14)) Text("Genau vor einem Jahr")
Text("vor genau einem jahr") .font(.nightLabel(13, weight: .bold))
.font(.nightLabel(12, weight: .semibold)) .foregroundColor(.nightPrimary)
.foregroundColor(.nightPurpleSoft)
.kerning(0.5)
Spacer() Spacer()
Button { withAnimation { show = false } } label: { Button { withAnimation { show = false } } label: {
Image(systemName: "xmark") Image(systemName: "xmark")
@@ -99,163 +90,170 @@ struct MemoryBanner: View {
Text(post.content) Text(post.content)
.font(.nightBody(15)) .font(.nightBody(15))
.foregroundColor(.nightPrimary.opacity(0.85)) .foregroundColor(.nightPrimary.opacity(0.88))
.lineSpacing(4) .lineSpacing(5)
.multilineTextAlignment(.leading)
HStack(spacing: 5) { HStack {
if let mood = post.mood { if let mood = post.mood {
Text(mood.emoji).font(.nightMono(11)) HStack(spacing: 4) {
Text(mood.label).font(.nightLabel(11)) 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) Text(post.formattedTime)
.font(.nightMono(11)) .font(.nightLabel(10))
.foregroundColor(.nightTertiary)
} }
.foregroundColor(.nightTertiary)
} }
.padding(16) .padding(18)
.background( .background(.ultraThinMaterial.opacity(0.5))
ZStack { .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
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( .overlay(
Rectangle().fill(Color.nightBorder).frame(height: 1), RoundedRectangle(cornerRadius: 20, style: .continuous)
alignment: .bottom .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 { struct DiaryPostRow: View {
let post: Post let post: Post
let onDelete: () -> Void let onDelete: () -> Void
@State private var confirmDelete = false @State private var confirmDelete = false
@State private var isContentRevealed = false
var body: some View { var body: some View {
HStack(alignment: .top, spacing: 0) { HStack(spacing: 0) {
Rectangle() moodStripe
.fill(post.mood?.color ?? Color.nightTertiary) mainContent
.frame(width: 2) }
.padding(.vertical, 14) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.contextMenu {
VStack(alignment: .leading, spacing: 8) { Button(role: .destructive) {
HStack { confirmDelete = true
Text(post.formattedTime) } label: {
.font(.nightMono(12)) Label("Löschen", systemImage: "trash")
.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( .confirmationDialog("Post löschen?", isPresented: $confirmDelete, titleVisibility: .visible) {
"Post löschen?",
isPresented: $confirmDelete,
titleVisibility: .visible
) {
Button("Löschen", role: .destructive) { onDelete() } Button("Löschen", role: .destructive) { onDelete() }
} message: { } 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 { struct DiaryEmptyView: View {
var body: some View { var body: some View {
VStack(spacing: 18) { VStack(spacing: 24) {
Image(systemName: "book.closed") Image(systemName: "moon.stars")
.font(.system(size: 48)) .font(.system(size: 52))
.foregroundColor(.nightTertiary) .foregroundColor(.nightPurple.opacity(0.25))
Text("noch nichts hier")
.font(.nightTitle(18)) VStack(spacing: 8) {
.foregroundColor(.nightPrimary) Text("Noch keine Erinnerungen")
Text("Deine Posts landen hier.\nIn einem Jahr kannst du nachlesen, was dich\nmitten in der Nacht beschäftigt hat.") .font(.nightTitle(20))
.font(.nightBody(15)) .foregroundColor(.nightPrimary)
.foregroundColor(.nightSecondary)
.multilineTextAlignment(.center) Text("Deine Gedanken landen hier\nund bleiben für immer sicher verwahrt.")
.lineSpacing(4) .font(.nightBody(15))
.foregroundColor(.nightSecondary)
.multilineTextAlignment(.center)
.lineSpacing(4)
}
} }
.padding(40) .padding(40)
} }
} }
// MARK: - ViewModel
@MainActor @MainActor
class DiaryViewModel: ObservableObject { class DiaryViewModel: ObservableObject {
struct NightGroup { let nightLabel: String; let posts: [Post] } struct NightGroup { let nightLabel: String; let posts: [Post] }
@@ -271,27 +269,19 @@ class DiaryViewModel: ObservableObject {
do { do {
let posts = try await supabase.getDiary() let posts = try await supabase.getDiary()
// Gruppiere nach Nacht (Datum - 2h, damit 2–5 Uhr zur selben Nacht gehört) groupedPosts = Dictionary(grouping: posts) { post -> String in
let grouped = Dictionary(grouping: posts) { post -> String in post.formattedNightDate
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)
} }
.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())! let oneYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: Date())!
yearAgoPost = posts.first { post in yearAgoPost = posts.first { post in
Calendar.current.isDate(post.createdAt, equalTo: oneYearAgo, toGranularity: .day) Calendar.current.isDate(post.createdAt, equalTo: oneYearAgo, toGranularity: .day)
} }
} catch { } catch {
#if DEBUG #if DEBUG
groupedPosts = [NightGroup(nightLabel: "gestern", posts: Post.previews)] groupedPosts = [NightGroup(nightLabel: "Gestern", posts: Post.previews)]
#endif #endif
} }
} }
+86 -157
View File
@@ -1,31 +1,24 @@
import SwiftUI import SwiftUI
// MARK: - Feed
struct FeedView: View { struct FeedView: View {
@StateObject private var viewModel = FeedViewModel() @StateObject private var viewModel = FeedViewModel()
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
var realtime: RealtimeService? = nil var realtime: RealtimeService? = nil
@State private var postsAppeared = false @State private var postsAppeared = false
// Gerade Jetzt = posts younger than 10 minutes
var rightNowPosts: [Post] { viewModel.posts.filter { $0.isRightNow } } 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 { var body: some View {
NavigationStack { NavigationStack {
ZStack { ZStack {
Color.nightBase.ignoresSafeArea() Color.clear.ignoresSafeArea()
if viewModel.posts.isEmpty && !viewModel.isLoading { if viewModel.posts.isEmpty && !viewModel.isLoading {
EmptyNightView(windowState: appState.windowState) EmptyNightView(windowState: appState.windowState)
} else { } else {
ScrollView { ScrollView {
LazyVStack(spacing: 0) { LazyVStack(spacing: 16) {
// ── 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 { if !rightNowPosts.isEmpty && appState.windowState == .posted {
RightNowSection( RightNowSection(
posts: rightNowPosts, posts: rightNowPosts,
@@ -33,42 +26,40 @@ struct FeedView: View {
Task { await viewModel.resonate(post) } Task { await viewModel.resonate(post) }
} }
) )
.padding(.bottom, 2) .padding(.horizontal, 16)
} }
// ── TRENNLINIE MIT KONTEXT ──────────────────────
NightContextBar( NightContextBar(
windowState: appState.windowState, windowState: appState.windowState,
totalCount: nightPosts.count, totalCount: nightPosts.count,
liveCount: rightNowPosts.count liveCount: rightNowPosts.count
) )
.padding(.horizontal, 20)
// ── HEUTE NACHT ──────────────────────────────── ForEach(nightPosts) { post in
// Alle Posts dieser Nacht, chronologisch
ForEach(Array(nightPosts.enumerated()), id: \.element.id) { index, post in
PostRowView(post: post) { PostRowView(post: post) {
Task { await viewModel.resonate(post) } Task { await viewModel.resonate(post) }
} }
.opacity(postsAppeared ? 1 : 0) .padding(.horizontal, 16)
.offset(y: postsAppeared ? 0 : 14) .transition(.opacity.combined(with: .move(edge: .bottom)))
.animation(
.spring(duration: 0.35, bounce: 0.1)
.delay(Double(min(index, 10)) * 0.04),
value: postsAppeared
)
Divider()
.background(Color.nightBorder)
.padding(.leading, 16)
} }
.onAppear { postsAppeared = true }
if viewModel.isLoading { if viewModel.isLoading {
ForEach(0..<5, id: \.self) { _ in ForEach(0..<4, id: \.self) { _ in
SkeletonPostRow() 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) Color.clear.frame(height: 100)
} }
} }
@@ -93,13 +84,11 @@ struct FeedView: View {
} }
} }
// MARK: - Wordmark
struct NightlyWordmark: View { struct NightlyWordmark: View {
var body: some View { var body: some View {
HStack(spacing: 7) { HStack(spacing: 6) {
Text("◐") Text("◐")
.font(.system(size: 15)) .font(.system(size: 14))
.foregroundColor(.nightPurple) .foregroundColor(.nightPurple)
Text("nightly") Text("nightly")
.font(.system(size: 17, weight: .bold, design: .rounded)) .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 { struct RightNowSection: View {
let posts: [Post] let posts: [Post]
let onResonate: (Post) -> Void let onResonate: (Post) -> Void
@@ -129,68 +105,55 @@ struct RightNowSection: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Header HStack(spacing: 8) {
HStack(spacing: 10) {
// Pulsierender grüner Punkt = LIVE
ZStack { ZStack {
Circle() Circle()
.fill(Color.nightGreen.opacity(0.25)) .fill(Color.nightGreen.opacity(0.3))
.frame(width: 16, height: 16) .frame(width: 14, height: 14)
.scaleEffect(pulse ? 1.8 : 1.0) .scaleEffect(pulse ? 2.0 : 1.0)
.opacity(pulse ? 0 : 1) .opacity(pulse ? 0 : 1)
Circle() Circle()
.fill(Color.nightGreen) .fill(Color.nightGreen)
.frame(width: 7, height: 7) .frame(width: 6, height: 6)
} }
.onAppear { .onAppear {
withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: false)) { withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: false)) { pulse = true }
pulse = true
}
} }
Text("gerade jetzt") Text("gerade jetzt")
.font(.nightLabel(12, weight: .semibold)) .font(.nightLabel(11, weight: .semibold))
.foregroundColor(.nightGreen) .foregroundColor(.nightGreen)
.kerning(0.8) .kerning(0.8)
Text("· \(posts.count) \(posts.count == 1 ? "Person" : "Personen") gleichzeitig wach") Text("· \(posts.count) \(posts.count == 1 ? "Person" : "Personen") gleichzeitig wach")
.font(.nightLabel(12)) .font(.nightLabel(11))
.foregroundColor(.nightSecondary) .foregroundColor(.nightSecondary)
Spacer() 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(.horizontal, 14)
.padding(.top, 16) .padding(.top, 14)
.padding(.bottom, 12) .padding(.bottom, 10)
// Horizontal Cards
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) { HStack(spacing: 10) {
ForEach(posts) { post in ForEach(posts) { post in
RightNowCard(post: post, onResonate: { onResonate(post) }) RightNowCard(post: post, onResonate: { onResonate(post) })
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 14)
.padding(.bottom, 16) .padding(.bottom, 14)
} }
} }
.background( .background(Color.nightSurface)
ZStack { .overlay(alignment: .bottom) {
Color.nightSurface
Color.nightGreen.opacity(0.025)
}
)
.overlay(
Rectangle() Rectangle()
.fill(Color.nightGreen.opacity(0.15)) .fill(Color.nightGreen.opacity(0.1))
.frame(height: 1), .frame(height: 0.5)
alignment: .bottom }
) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} }
} }
@@ -199,72 +162,54 @@ struct RightNowCard: View {
let onResonate: () -> Void let onResonate: () -> Void
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) { HStack(spacing: 6) {
if post.isAnonymous { if post.isAnonymous {
AnonymousAvatar(size: 26) AnonymousAvatar(size: 22)
} else { } else {
AvatarView(user: post.author, size: 26) AvatarView(user: post.author, size: 22)
} }
Text(post.isAnonymous ? "anonym" : "@\(post.author.username)") Text(post.isAnonymous ? "anonym" : "@\(post.author.username)")
.font(.nightLabel(12)) .font(.nightLabel(10, weight: .medium))
.foregroundColor(post.isAnonymous ? .nightSecondary : .nightPrimary) .foregroundColor(post.isAnonymous ? .nightTertiary : .nightPrimary)
.lineLimit(1) .lineLimit(1)
Spacer() Spacer()
if let mood = post.mood { if let mood = post.mood {
Text(mood.emoji) Text(mood.emoji)
.font(.nightMono(11)) .font(.system(size: 10))
.foregroundColor(mood.color.opacity(0.7))
} }
} }
Text(post.content) Text(post.content)
.font(.nightBody(14)) .font(.nightBody(13))
.foregroundColor(.nightPrimary.opacity(0.88)) .foregroundColor(.nightPrimary.opacity(0.85))
.lineSpacing(4) .lineSpacing(3)
.lineLimit(4) .lineLimit(3)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0) Spacer(minLength: 0)
Button(action: onResonate) { Button(action: onResonate) {
HStack(spacing: 4) { HStack(spacing: 3) {
Image(systemName: post.hasResonated ? "heart.fill" : "heart") Image(systemName: post.hasResonated ? "heart.fill" : "heart")
.font(.system(size: 12)) .font(.system(size: 11))
.foregroundColor(post.hasResonated ? .nightRed : .nightSecondary) .foregroundColor(post.hasResonated ? .nightRed : .nightTertiary)
if post.resonanceCount > 0 { if post.resonanceCount > 0 {
Text("\(post.resonanceCount)") Text("\(post.resonanceCount)")
.font(.nightLabel(11)) .font(.nightLabel(10))
.foregroundColor(.nightSecondary) .foregroundColor(.nightTertiary)
} }
} }
} }
} }
.padding(14) .padding(12)
.frame(width: 210, height: 148) .frame(width: 200, height: 130)
.background( .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(.ultraThinMaterial.opacity(0.3)))
ZStack { .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Color.white.opacity(0.05), lineWidth: 0.5))
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 { struct NightContextBar: View {
let windowState: AppState.WindowState let windowState: AppState.WindowState
let totalCount: Int let totalCount: Int
@@ -272,63 +217,53 @@ struct NightContextBar: View {
var body: some View { var body: some View {
HStack { HStack {
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) { HStack(spacing: 5) {
Text("heute nacht") Text("heute nacht")
.font(.nightLabel(12, weight: .semibold)) .font(.nightLabel(11, weight: .semibold))
.foregroundColor(.nightSecondary) .foregroundColor(.nightSecondary)
.kerning(0.5) .kerning(0.4)
if totalCount > 0 { if totalCount > 0 {
Text("· \(totalCount) Gedanken") Text("· \(totalCount) Gedanken")
.font(.nightLabel(12)) .font(.nightLabel(11))
.foregroundColor(.nightTertiary) .foregroundColor(.nightTertiary)
} }
} }
Text(statusSubtitle) Text(statusSubtitle)
.font(.nightLabel(11)) .font(.nightLabel(10))
.foregroundColor(.nightTertiary) .foregroundColor(.nightTertiary)
} }
Spacer() Spacer()
// Window status pill HStack(spacing: 4) {
HStack(spacing: 5) {
Circle() Circle()
.fill(windowState == .open ? Color.nightGreen : Color.nightTertiary) .fill(windowState == .open || windowState == .posted ? Color.nightGreen : Color.nightTertiary)
.frame(width: 6, height: 6) .frame(width: 5, height: 5)
Text(windowState == .open ? "offen" : "geschlossen") Text(windowState == .open || windowState == .posted ? "offen" : "geschlossen")
.font(.nightLabel(11)) .font(.nightLabel(10, weight: .medium))
.foregroundColor(.nightSecondary) .foregroundColor(.nightSecondary)
} }
.padding(.horizontal, 10) .padding(.horizontal, 8)
.padding(.vertical, 5) .padding(.vertical, 4)
.background(Color.nightRaised) .background(Color.nightRaised)
.clipShape(Capsule()) .clipShape(Capsule())
} }
.padding(.horizontal, 16) .padding(.vertical, 10)
.padding(.vertical, 12)
.overlay(
Rectangle()
.fill(Color.nightBorder)
.frame(height: 1),
alignment: .bottom
)
} }
var statusSubtitle: String { var statusSubtitle: String {
switch windowState { switch windowState {
case .open: return "Du kannst noch posten — bis 05:00" case .open: return "Du kannst posten"
case .posted: return "Dein Post ist sichtbar bis morgen früh" case .posted: return "Du kannst weitersposten"
case .closed: return "Fenster öffnet später heute Nacht" case .closed: return "Fenster öffnet später"
case .missed: return "Nächste Chance: heute Nacht" case .missed: return "Nächste Chance heute Nacht"
} }
} }
} }
// MARK: - Help Tooltip
struct HelpTooltip: View { struct HelpTooltip: View {
let text: String let text: String
@State private var show = false @State private var show = false
@@ -341,7 +276,7 @@ struct HelpTooltip: View {
} }
} label: { } label: {
Image(systemName: "info.circle") Image(systemName: "info.circle")
.font(.system(size: 13)) .font(.system(size: 12))
.foregroundColor(.nightTertiary) .foregroundColor(.nightTertiary)
} }
.overlay(alignment: .topTrailing) { .overlay(alignment: .topTrailing) {
@@ -350,11 +285,7 @@ struct HelpTooltip: View {
.font(.nightBody(12)) .font(.nightBody(12))
.foregroundColor(.nightPrimary) .foregroundColor(.nightPrimary)
.padding(10) .padding(10)
.background( .background(RoundedRectangle(cornerRadius: 10).fill(Color.nightRaised).shadow(color: .black.opacity(0.4), radius: 8, y: 4))
RoundedRectangle(cornerRadius: 10)
.fill(Color.nightRaised)
.shadow(color: .black.opacity(0.4), radius: 8, y: 4)
)
.frame(width: 200) .frame(width: 200)
.offset(x: -160, y: 28) .offset(x: -160, y: 28)
.transition(.opacity.combined(with: .scale(scale: 0.9, anchor: .topTrailing))) .transition(.opacity.combined(with: .scale(scale: 0.9, anchor: .topTrailing)))
@@ -364,16 +295,14 @@ struct HelpTooltip: View {
} }
} }
// MARK: - Empty State
struct EmptyNightView: View { struct EmptyNightView: View {
let windowState: AppState.WindowState let windowState: AppState.WindowState
var body: some View { var body: some View {
VStack(spacing: 20) { VStack(spacing: 24) {
Text("◐") Text("◐")
.font(.system(size: 52)) .font(.system(size: 48))
.foregroundColor(.nightPurple.opacity(0.4)) .foregroundColor(.nightPurple.opacity(0.3))
VStack(spacing: 8) { VStack(spacing: 8) {
Text(windowState == .open ? "sei der erste heute nacht" : "noch ruhig hier") Text(windowState == .open ? "sei der erste heute nacht" : "noch ruhig hier")
+5 -7
View File
@@ -65,23 +65,21 @@ struct ImpressumContent: View {
// ⚠️ PFLICHTANGABEN — vor Launch ausfüllen! // ⚠️ PFLICHTANGABEN — vor Launch ausfüllen!
LegalParagraph(title: "Angaben gemäß § 5 TMG") { LegalParagraph(title: "Angaben gemäß § 5 TMG") {
""" """
[DEIN NAME] Dennis Konkol
[STRASSE HAUSNUMMER] Auf dem Ziegenbrink 2B
[PLZ ORT] 49082 Osnabrück
Deutschland Deutschland
⚠️ Vor Launch ausfüllen!
""" """
} }
LegalParagraph(title: "Kontakt") { 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") { 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") { LegalParagraph(title: "Hinweis") {
""" """
+50 -36
View File
@@ -6,33 +6,41 @@ struct MainTabView: View {
@State private var selectedTab = 0 @State private var selectedTab = 0
@State private var showCompose = false @State private var showCompose = false
@State private var showSettings = false @State private var showSettings = false
@State private var showWhispers = false
var body: some View { var body: some View {
ZStack(alignment: .bottom) { ZStack(alignment: .bottom) {
Color.nightBase.ignoresSafeArea()
// Content // Content
TabContent( TabContent(
selectedTab: selectedTab, selectedTab: selectedTab,
realtime: realtime realtime: realtime
) )
.environmentObject(appState) .environmentObject(appState)
.safeAreaPadding(.bottom, 80) // Slightly less space, more room for content
// Floating Tab Bar // Floating Tab Bar
FloatingTabBar( VStack {
selectedTab: $selectedTab, Spacer()
windowState: appState.windowState, FloatingTabBar(
onCompose: { showCompose = true }, selectedTab: $selectedTab,
onSettings: { showSettings = true } 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) { .sheet(isPresented: $showCompose) {
ComposeView().environmentObject(appState) ComposeView().environmentObject(appState)
} }
.sheet(isPresented: $showSettings) { .sheet(isPresented: $showSettings) {
SettingsView().environmentObject(appState) SettingsView().environmentObject(appState)
} }
.sheet(isPresented: $showWhispers) {
WhisperListView()
}
.onDisappear { .onDisappear {
Task { await realtime.stopListening() } Task { await realtime.stopListening() }
} }
@@ -60,10 +68,7 @@ private struct TabContent: View {
.scaleEffect(selectedTab == 1 ? 1 : 0.97) .scaleEffect(selectedTab == 1 ? 1 : 0.97)
.allowsHitTesting(selectedTab == 1) .allowsHitTesting(selectedTab == 1)
ProfileView( ProfileView()
user: appState.currentUser ?? .preview,
isCurrentUser: true
)
.environmentObject(appState) .environmentObject(appState)
.opacity(selectedTab == 2 ? 1 : 0) .opacity(selectedTab == 2 ? 1 : 0)
.scaleEffect(selectedTab == 2 ? 1 : 0.97) .scaleEffect(selectedTab == 2 ? 1 : 0.97)
@@ -80,6 +85,7 @@ struct FloatingTabBar: View {
let windowState: AppState.WindowState let windowState: AppState.WindowState
let onCompose: () -> Void let onCompose: () -> Void
let onSettings: () -> Void let onSettings: () -> Void
let onWhispers: () -> Void
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
@@ -112,25 +118,23 @@ struct FloatingTabBar: View {
Spacer() Spacer()
// Whispers
TabIcon(icon: "bubble.left", activeIcon: "bubble.left.fill", isSelected: false) {
Haptics.select()
onWhispers()
}
Spacer()
// Settings // Settings
TabIcon(icon: "gearshape", activeIcon: "gearshape.fill", isSelected: false) { TabIcon(icon: "gearshape", activeIcon: "gearshape.fill", isSelected: false) {
Haptics.select() Haptics.select()
onSettings() onSettings()
} }
} }
.padding(.horizontal, 20) .padding(.vertical, 8)
.padding(.vertical, 10) .frame(maxWidth: 400) // Safety cap for larger devices
.padding(.bottom, 18) .glassStyle(opacity: 0.5)
.background(
Rectangle()
.fill(.ultraThinMaterial.opacity(0.8))
.background(Color.nightBase.opacity(0.85))
.ignoresSafeArea()
)
.overlay(
Rectangle().fill(Color.nightBorder).frame(height: 1),
alignment: .top
)
} }
} }
@@ -157,12 +161,11 @@ struct ComposeTabButton: View {
var body: some View { var body: some View {
Button { Button {
guard windowState == .open else { return }
Haptics.medium() Haptics.medium()
onTap() onTap()
} label: { } label: {
ZStack { ZStack {
if windowState == .open { if windowState == .open || windowState == .posted {
Circle() Circle()
.fill(Color.nightPurple.opacity(0.18)) .fill(Color.nightPurple.opacity(0.18))
.frame(width: 62, height: 62) .frame(width: 62, height: 62)
@@ -174,7 +177,7 @@ struct ComposeTabButton: View {
.frame(width: 50, height: 50) .frame(width: 50, height: 50)
Image(systemName: buttonIcon) Image(systemName: buttonIcon)
.font(.system(size: 19, weight: .semibold)) .font(.system(size: 19, weight: .semibold))
.foregroundColor(.white) .foregroundColor(buttonIconColor)
} }
} }
.onAppear { glow = true } .onAppear { glow = true }
@@ -190,19 +193,30 @@ struct ComposeTabButton: View {
)) ))
case .posted: case .posted:
return AnyShapeStyle(LinearGradient( return AnyShapeStyle(LinearGradient(
colors: [Color(hex: "059669"), Color(hex: "047857")], colors: [Color(hex: "8B5CF6"), Color(hex: "6D28D9")],
startPoint: .top, endPoint: .bottom 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 { var buttonIcon: String {
switch windowState { switch windowState {
case .open: return "plus" case .open: return "plus"
case .posted: return "checkmark" case .posted: return "plus"
default: return "moon.zzz" 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)
} }
} }
} }
+136 -137
View File
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import Combine
struct OnboardingView: View { struct OnboardingView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@@ -8,98 +9,68 @@ struct OnboardingView: View {
enum Phase { case welcome, auth } enum Phase { case welcome, auth }
var body: some View { var body: some View {
ZStack { GeometryReader { geo in
Color.nightBase.ignoresSafeArea() ZStack {
StarField() // Background tap to dismiss
Color.clear
.contentShape(Rectangle())
.onTapGesture {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
VStack(spacing: 0) { ScrollView(showsIndicators: false) {
Spacer() VStack(spacing: 0) {
switch phase {
switch phase { case .welcome:
case .welcome: WelcomeScreen(onStart: { phase = .auth }, onLogin: { phase = .auth; isLogin = true })
WelcomeScreen() .transition(.move(edge: .leading).combined(with: .opacity))
.transition(.opacity) case .auth:
Spacer() AuthScreen(isLogin: $isLogin)
WelcomeActions( .transition(.move(edge: .trailing).combined(with: .opacity))
onStart: {
isLogin = false
withAnimation(.spring(duration: 0.4)) { phase = .auth }
},
onLogin: {
isLogin = true
withAnimation(.spring(duration: 0.4)) { phase = .auth }
} }
) }
.frame(minHeight: geo.size.height)
case .auth:
AuthScreen(isLogin: $isLogin)
.environmentObject(appState)
.transition(.move(edge: .trailing).combined(with: .opacity))
Spacer()
} }
} }
} }
.animation(.spring(duration: 0.6, bounce: 0.2), value: phase)
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
} }
} }
// MARK: - Welcome // MARK: - Welcome Screen
struct WelcomeScreen: View { 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 onStart: () -> Void
let onLogin: () -> Void let onLogin: () -> Void
var body: some View { var body: some View {
VStack(spacing: 12) { VStack(spacing: 40) {
Button("loslegen", action: onStart).buttonStyle(NightlyPrimaryButton()) VStack(spacing: 16) {
Button("ich hab schon einen account", action: onLogin) Text("nightly")
.font(.nightLabel(14)) .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) .foregroundColor(.nightSecondary)
.padding(.top, 4)
}
} }
.padding(.horizontal, 24) .padding(32)
.padding(.bottom, 52)
} }
} }
@@ -109,7 +80,6 @@ struct AuthScreen: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@Binding var isLogin: Bool @Binding var isLogin: Bool
// Registrierung
@State private var username = "" @State private var username = ""
@State private var displayName = "" @State private var displayName = ""
@State private var email = "" @State private var email = ""
@@ -117,53 +87,81 @@ struct AuthScreen: View {
@State private var isLoading = false @State private var isLoading = false
@State private var error: String? @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 { var body: some View {
VStack(spacing: 22) { VStack(spacing: 28) {
Text(isLogin ? "willkommen zurück" : "mitmachen") VStack(spacing: 8) {
.font(.nightTitle(28)) Text(isLogin ? "Willkommen zurück" : "Mitmachen")
.foregroundColor(.nightPrimary) .font(.nightTitle(32))
.foregroundColor(.nightPrimary)
VStack(spacing: 10) { Text(isLogin ? "Logge dich in dein System ein." : "Erstelle deinen anonymen Zugang.")
.font(.nightBody(15))
.foregroundColor(.nightSecondary)
}
VStack(spacing: 12) {
if !isLogin { if !isLogin {
NightlyField(text: $displayName, placeholder: "anzeigename", icon: "person") NightlyField(text: $displayName, placeholder: "Anzeigename", icon: "person")
NightlyField(text: $username, placeholder: "benutzername", icon: "at") NightlyField(text: $username, placeholder: "Benutzername", icon: "at")
.textInputAutocapitalization(.never).autocorrectionDisabled() .textInputAutocapitalization(.never).autocorrectionDisabled()
} }
NightlyField(text: $email, placeholder: "e-mail", icon: "envelope") NightlyField(text: $email, placeholder: "E-Mail", icon: "envelope")
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.keyboardType(.emailAddress) .keyboardType(.emailAddress)
.autocorrectionDisabled() .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 { if let err = error {
Text(err) Text(err)
.font(.nightLabel(13)) .font(.nightLabel(13))
.foregroundColor(.nightRed) .foregroundColor(.nightRed)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, 24)
} }
Button(isLogin ? "einloggen" : "account erstellen") { if let info = infoMessage {
Task { await submit() } Text(info)
.font(.nightLabel(13))
.foregroundColor(.nightGreen)
.multilineTextAlignment(.center)
} }
.buttonStyle(NightlyPrimaryButton(isLoading: isLoading))
.disabled(isLoading)
.padding(.horizontal, 24)
Button(isLogin ? "noch kein account?" : "schon dabei?") { VStack(spacing: 16) {
withAnimation { isLogin.toggle() } Button(isLogin ? "Einloggen" : "Account erstellen") {
Task { await submit() }
}
.buttonStyle(NightlyPrimaryButton(isLoading: isLoading))
.disabled(isLoading)
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)
} }
.font(.nightLabel(14))
.foregroundColor(.nightSecondary)
// Rechtliches
LegalNotice() LegalNotice()
} }
.padding(32)
} }
func submit() async { func submit() async {
@@ -173,6 +171,7 @@ struct AuthScreen: View {
} }
isLoading = true isLoading = true
error = nil error = nil
infoMessage = nil
defer { isLoading = false } defer { isLoading = false }
do { do {
@@ -202,8 +201,31 @@ struct AuthScreen: View {
self.error = error.localizedDescription 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 { struct LegalNotice: View {
@State private var showLegal = false @State private var showLegal = false
@@ -214,9 +236,9 @@ struct LegalNotice: View {
.foregroundColor(.nightTertiary) .foregroundColor(.nightTertiary)
HStack(spacing: 4) { HStack(spacing: 4) {
Button("Nutzungsbedingungen") { showLegal = true } Button("AGB") { showLegal = true }
Text("·") Text("·")
Button("Datenschutzerklärung") { showLegal = true } Button("Datenschutz") { showLegal = true }
} }
.font(.nightLabel(11, weight: .medium)) .font(.nightLabel(11, weight: .medium))
.foregroundColor(.nightSecondary) .foregroundColor(.nightSecondary)
@@ -228,8 +250,6 @@ struct LegalNotice: View {
} }
} }
// MARK: - Reusable components
struct NightlyField: View { struct NightlyField: View {
@Binding var text: String @Binding var text: String
let placeholder: String let placeholder: String
@@ -247,13 +267,18 @@ struct NightlyField: View {
if isSecure { SecureField(placeholder, text: $text) } if isSecure { SecureField(placeholder, text: $text) }
else { TextField(placeholder, text: $text) } else { TextField(placeholder, text: $text) }
} }
.font(.nightBody(16)) .font(.nightBody(17))
.foregroundColor(.nightPrimary) .foregroundColor(.nightPrimary)
} }
.padding(16) .padding(16)
.background(Color.nightSurface) .background(
.clipShape(RoundedRectangle(cornerRadius: 12)) RoundedRectangle(cornerRadius: 16, style: .continuous)
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Color.nightBorder, lineWidth: 1)) .fill(.ultraThinMaterial.opacity(0.3))
)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.white.opacity(0.1), lineWidth: 0.5)
)
.tint(.nightPurpleSoft) .tint(.nightPurpleSoft)
} }
} }
@@ -262,45 +287,19 @@ struct NightlyPrimaryButton: ButtonStyle {
var isLoading = false var isLoading = false
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
Group { Group {
if isLoading { ProgressView().tint(.nightBase).frame(maxWidth: .infinity, maxHeight: 52) } if isLoading { ProgressView().tint(.nightBase).frame(maxWidth: .infinity, maxHeight: 56) }
else { else {
configuration.label configuration.label
.font(.nightLabel(17, weight: .semibold)) .font(.nightLabel(17, weight: .semibold))
.foregroundColor(.nightBase) .foregroundColor(.nightBase)
.frame(maxWidth: .infinity).frame(height: 52) .frame(maxWidth: .infinity).frame(height: 56)
} }
} }
.background(Color.nightPrimary.opacity(configuration.isPressed ? 0.85 : 1)) .background(
.clipShape(RoundedRectangle(cornerRadius: 14)) RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color.nightPrimary.opacity(configuration.isPressed ? 0.8 : 1.0))
)
.scaleEffect(configuration.isPressed ? 0.98 : 1) .scaleEffect(configuration.isPressed ? 0.98 : 1)
.animation(.spring(duration: 0.2), value: configuration.isPressed) .animation(.spring(duration: 0.2), value: configuration.isPressed)
} }
} }
struct StarField: View {
struct Star: Identifiable {
let id: Int; let x, y, size, opacity: CGFloat
}
private let stars: [Star] = (0..<120).map {
Star(id: $0,
x: .random(in: 0...1),
y: .random(in: 0...1),
size: .random(in: 1...2.5),
opacity: .random(in: 0.07...0.3))
}
@State private var twinkle = false
var body: some View {
GeometryReader { geo in
ForEach(stars) { s in
Circle().fill(Color.white)
.frame(width: s.size, height: s.size)
.opacity(twinkle && s.id % 5 == 0 ? s.opacity * 0.3 : s.opacity)
.position(x: s.x * geo.size.width, y: s.y * geo.size.height)
}
}
.ignoresSafeArea()
.onAppear {
withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) { twinkle = true }
}
}
}
@@ -0,0 +1,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()
}
}
}
+173 -251
View File
@@ -1,292 +1,214 @@
import SwiftUI import SwiftUI
// MARK: - Post Row
struct PostRowView: View { struct PostRowView: View {
let post: Post let post: Post
let onResonate: () -> Void let onResonate: () -> Void
var onReport: (() -> Void)? = nil var onReport: (() -> Void)? = nil
@State private var showReport = false @State private var showReport = false
@State private var isContentRevealed = false
@State private var showProfile = false
var body: some View { var body: some View {
HStack(alignment: .top, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Mood accent bar — der einzige echte Farbakzent im Feed moodAccentBar
Rectangle() mainContent
.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)
} }
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.sheet(isPresented: $showReport) { .sheet(isPresented: $showReport) {
ReportSheet(postId: post.id) ReportSheet(postId: post.id)
} }
} }
}
// MARK: - Resonance Button var moodAccentBar: some View {
struct ResonanceButton: View {
let count: Int
let isActive: Bool
let action: () -> Void
@State private var scale: CGFloat = 1.0
var body: some View {
Button {
Haptics.light()
withAnimation(.spring(duration: 0.25, bounce: 0.7)) { scale = 1.4 }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation(.spring(duration: 0.2)) { scale = 1.0 }
}
action()
} label: {
HStack(spacing: 5) {
Image(systemName: isActive ? "heart.fill" : "heart")
.font(.system(size: 14))
.foregroundColor(isActive ? .nightRed : .nightSecondary)
.scaleEffect(scale)
Text(count > 0 ? "\(count)" : "hat mich getroffen")
.font(.nightLabel(13))
.foregroundColor(isActive ? .nightRed : .nightSecondary)
}
.padding(.vertical, 5)
.padding(.horizontal, count > 0 || isActive ? 10 : 0)
.background(
Capsule()
.fill(isActive ? Color.nightRed.opacity(0.1) : Color.clear)
)
}
.animation(.easeInOut(duration: 0.2), value: isActive)
}
}
// MARK: - Avatar
struct AvatarView: View {
let user: User
let size: CGFloat
var body: some View {
Group { Group {
if let url = user.avatarURL { if let mood = post.mood {
AsyncImage(url: url) { img in img.resizable().scaledToFill() } LinearGradient(
placeholder: { initials } colors: mood.colorGradient.map { $0.opacity(0.6) },
} else { initials } startPoint: .top,
} endPoint: .bottom
.frame(width: size, height: size) )
.clipShape(Circle()) .frame(width: 3)
} } else {
Color.nightTertiary.opacity(0.15)
var initials: some View { .frame(width: 1)
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 { var mainContent: some View {
let size: CGFloat VStack(alignment: .leading, spacing: 14) {
var body: some View { header
ZStack {
Circle().fill(Color.nightRaised) if post.hasContentWarning && !isContentRevealed {
Image(systemName: "questionmark") contentWarningOverlay
.font(.system(size: size * 0.35, weight: .semibold)) } else {
.foregroundColor(.nightSecondary) contentText
}
footer
} }
.frame(width: size, height: size) .padding(18)
} .background(
} RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(.ultraThinMaterial.opacity(0.35))
// MARK: - Report Sheet )
.overlay(
struct ReportSheet: View { RoundedRectangle(cornerRadius: 20, style: .continuous)
let postId: String .stroke(
@Environment(\.dismiss) var dismiss moodBorderColor,
@State private var selected: ReportReason? = nil lineWidth: 0.5
@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 { var moodBorderColor: Color {
NavigationStack { if let mood = post.mood {
ZStack { return mood.color.opacity(0.12)
Color.nightSurface.ignoresSafeArea() }
return Color.white.opacity(0.06)
}
if submitted { var header: some View {
VStack(spacing: 16) { HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill") if post.isAnonymous {
.font(.system(size: 48)) AnonymousAvatar(size: 30)
.foregroundColor(.nightGreen) } else {
Text("Danke für deine Meldung") Button {
.font(.nightTitle(18)) if let uuid = UUID(uuidString: post.author.id) {
.foregroundColor(.nightPrimary) showProfile = true
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) } label: {
} else { HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 0) { AvatarView(user: post.author, size: 30)
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 VStack(alignment: .leading, spacing: 1) {
Button { HStack(spacing: 5) {
selected = reason Text(post.author.displayName)
} label: { .font(.nightLabel(14, weight: .semibold))
HStack { .foregroundColor(.nightPrimary)
Text(reason.rawValue) .lineLimit(1)
.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() if let mood = post.mood {
Text(mood.emoji)
Button { .font(.system(size: 11))
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) Text(post.timeAgo)
.background(selected != nil ? Color.nightPrimary : Color.nightRaised) .font(.nightLabel(11))
.clipShape(RoundedRectangle(cornerRadius: 14)) .foregroundColor(.nightTertiary)
} }
.disabled(selected == nil || isLoading)
.padding(.horizontal, 20)
.padding(.bottom, 32)
} }
} }
.buttonStyle(.plain)
} }
.navigationBarTitleDisplayMode(.inline)
.toolbar { if post.isAnonymous {
ToolbarItem(placement: .navigationBarLeading) { VStack(alignment: .leading, spacing: 1) {
Button("Abbrechen") { dismiss() } HStack(spacing: 5) {
.foregroundColor(.nightSecondary) 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 { var contentWarningOverlay: some View {
isLoading = true Button {
defer { isLoading = false } withAnimation(.spring(duration: 0.4)) { isContentRevealed = true }
do { Haptics.light()
try await supabase.reportPost(postId: postId, reason: reason.rawValue, details: nil) } label: {
submitted = true VStack(spacing: 8) {
} catch { Image(systemName: post.contentWarningType?.icon ?? "exclamationmark.triangle")
// Fehler still ignorieren — Meldung trotzdem als abgeschlossen zeigen .font(.system(size: 22))
submitted = true .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)
}
} }
} }
} }
+172 -299
View File
@@ -1,356 +1,229 @@
import Combine
import SwiftUI import SwiftUI
struct ProfileView: View { struct ProfileView: View {
let user: User
let isCurrentUser: Bool
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@StateObject private var viewModel: ProfileViewModel @State private var isEditing = false
@State private var showSettings = false @State private var displayName = ""
@State private var postsAppeared = false @State private var bio = ""
@State private var isLoading = false
init(user: User, isCurrentUser: Bool) { @State private var errorMessage: String?
self.user = user @StateObject private var viewModel = ProfileViewModel2()
self.isCurrentUser = isCurrentUser
_viewModel = StateObject(wrappedValue: ProfileViewModel(userIdString: user.id))
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack { ZStack {
Color.nightBase.ignoresSafeArea() EtherealBackground().ignoresSafeArea()
ScrollView { ScrollView {
VStack(spacing: 0) { VStack(spacing: 32) {
if viewModel.isLoading { if let user = appState.currentUser {
SkeletonProfileHeader() VStack(spacing: 20) {
} else { AvatarView(user: user, size: 100)
ProfileHeader( .shadow(color: .black.opacity(0.2), radius: 20, y: 10)
user: user,
streak: viewModel.streak,
isCurrentUser: isCurrentUser
)
}
Divider().background(Color.nightBorder) VStack(spacing: 4) {
Text(user.displayName)
.font(.nightTitle(28))
.foregroundColor(.nightPrimary)
// Posts Text("@\(user.username)")
if viewModel.isLoading { .font(.nightLabel(16))
ForEach(0..<4, id: \.self) { _ in .foregroundColor(.nightPurpleSoft)
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)
} }
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)
.navigationBarTitleDisplayMode(.inline) .padding(.leading, 8)
.toolbar {
if isCurrentUser { VStack(spacing: 12) {
ToolbarItem(placement: .navigationBarTrailing) { ActionRow(icon: "heart.fill", title: "Resonanzen erhalten", value: viewModel.isLoading ? "…" : "\(viewModel.resonanceCount)")
Button { ActionRow(icon: "text.bubble.fill", title: "Gedanken geteilt", value: viewModel.isLoading ? "…" : "\(viewModel.postCount)")
showSettings = true ActionRow(icon: "moon.stars.fill", title: "Nächte in Folge", value: viewModel.isLoading ? "…" : "\(viewModel.streak)")
} label: { }
Image(systemName: "gearshape")
.foregroundColor(.nightSecondary)
} }
.padding(.horizontal, 16)
} }
.padding(.bottom, 120)
} }
} }
.sheet(isPresented: $showSettings) { .navigationTitle("Profil")
SettingsView().environmentObject(appState) .navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $isEditing) {
EditProfileSheet(displayName: $displayName, bio: $bio, onSave: saveProfile)
} }
} }
.task { await viewModel.load() } .task { await viewModel.load() }
} }
}
// MARK: - Profile Header func saveProfile() async {
isLoading = true
struct ProfileHeader: View { errorMessage = nil
let user: User
let streak: Int
let isCurrentUser: Bool
@State private var isFollowing: Bool
@State private var isFollowLoading = false
// Staggered entrance
@State private var avatarAppeared = false
@State private var infoAppeared = false
@State private var statsAppeared = false
@State private var actionAppeared = false
init(user: User, streak: Int, isCurrentUser: Bool) {
self.user = user
self.streak = streak
self.isCurrentUser = isCurrentUser
_isFollowing = State(initialValue: user.isFollowing)
}
var body: some View {
VStack(spacing: 0) {
VStack(spacing: 16) {
// Avatar mit Glow Ring
ZStack {
Circle()
.fill(
RadialGradient(
colors: [Color.nightPurple.opacity(0.12), .clear],
center: .center,
startRadius: 34,
endRadius: 52
)
)
.frame(width: 96, height: 96)
Circle()
.strokeBorder(
LinearGradient(
colors: [Color.nightPurple.opacity(0.35), Color.nightPurpleSoft.opacity(0.1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 1.5
)
.frame(width: 82, height: 82)
AvatarView(user: user, size: 76)
}
.scaleEffect(avatarAppeared ? 1 : 0.8)
.opacity(avatarAppeared ? 1 : 0)
// Name, Username, Bio
VStack(spacing: 4) {
Text(user.displayName)
.font(.nightTitle(22))
.foregroundColor(.nightPrimary)
Text("@\(user.username)")
.font(.nightLabel(14))
.foregroundColor(.nightSecondary)
if let bio = user.bio {
Text(bio)
.font(.nightBody(14))
.foregroundColor(.nightPrimary.opacity(0.75))
.multilineTextAlignment(.center)
.padding(.top, 4)
}
}
.opacity(infoAppeared ? 1 : 0)
.offset(y: infoAppeared ? 0 : 8)
// Stats
HStack(spacing: 36) {
ProfileStat(value: user.postCount, label: "nächte")
ProfileStat(value: user.followerCount, label: "follower")
ProfileStat(value: user.followingCount, label: "following")
}
.opacity(statsAppeared ? 1 : 0)
.offset(y: statsAppeared ? 0 : 10)
// Streak
if streak > 0 {
StreakBadge(streak: streak)
.opacity(actionAppeared ? 1 : 0)
.offset(y: actionAppeared ? 0 : 8)
}
// Action button
if isCurrentUser {
Button("profil bearbeiten") {}
.font(.nightLabel(14, weight: .medium))
.foregroundColor(.nightPrimary)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.nightRaised)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color.nightBorder, lineWidth: 1)
)
.padding(.horizontal, 48)
.opacity(actionAppeared ? 1 : 0)
.offset(y: actionAppeared ? 0 : 8)
} else {
FollowButton(
isFollowing: $isFollowing,
isLoading: $isFollowLoading,
onToggle: { Task { await toggleFollow() } }
)
.opacity(actionAppeared ? 1 : 0)
.offset(y: actionAppeared ? 0 : 8)
}
}
.padding(.horizontal, 20)
.padding(.top, 28)
.padding(.bottom, 20)
}
.onAppear {
withAnimation(.spring(duration: 0.6, bounce: 0.3).delay(0.05)) { avatarAppeared = true }
withAnimation(.spring(duration: 0.6, bounce: 0.2).delay(0.15)) { infoAppeared = true }
withAnimation(.spring(duration: 0.5, bounce: 0.2).delay(0.25)) { statsAppeared = true }
withAnimation(.spring(duration: 0.5, bounce: 0.2).delay(0.35)) { actionAppeared = true }
}
}
func toggleFollow() async {
Haptics.light()
isFollowLoading = true
defer { isFollowLoading = false }
guard let uid = UUID(uuidString: user.id) else { return }
do { do {
if isFollowing { try await supabase.updateProfile(displayName: displayName, bio: bio)
try await supabase.unfollow(userId: uid) if var user = appState.currentUser {
} else { user.displayName = displayName
try await supabase.follow(userId: uid) user.bio = bio
} appState.currentUser = user
withAnimation(.spring(duration: 0.3, bounce: 0.4)) {
isFollowing.toggle()
} }
isEditing = false
Haptics.success() Haptics.success()
} catch { } catch {
errorMessage = error.localizedDescription
Haptics.error() 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 { func load() async {
@Binding var isFollowing: Bool isLoading = true
@Binding var isLoading: Bool defer { isLoading = false }
let onToggle: () -> Void
var body: some View { guard let uid = await supabase.currentUserId else { return }
Button(action: onToggle) {
Group { do {
if isLoading { async let statsTask = supabase.getProfileStats(userId: uid)
ProgressView().tint(isFollowing ? .nightPrimary : .nightBase) async let streakTask = supabase.getStreak(userId: uid)
} else { let (stats, streakVal) = try await (statsTask, streakTask)
Text(isFollowing ? "entfolgen" : "folgen") postCount = stats.postCount
.font(.nightLabel(14, weight: .semibold)) resonanceCount = stats.resonanceCount
.foregroundColor(isFollowing ? .nightPrimary : .nightBase) streak = streakVal
} } catch {
} let diary = (try? await supabase.getDiary()) ?? []
.frame(maxWidth: .infinity) postCount = diary.count
.padding(.vertical, 10)
.background(isFollowing ? Color.nightRaised : Color.nightPrimary)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(isFollowing ? Color.nightBorder : .clear, lineWidth: 1)
)
} }
.disabled(isLoading)
.padding(.horizontal, 48)
.animation(.spring(duration: 0.3, bounce: 0.3), value: isFollowing)
} }
} }
// MARK: - Streak Badge struct EditProfileSheet: View {
@Binding var displayName: String
struct StreakBadge: View { @Binding var bio: String
let streak: Int @Environment(\.dismiss) var dismiss
@State private var fireScale: CGFloat = 1.0 var onSave: () async -> Void
@State private var isSaving = false
var isHot: Bool { streak >= 7 }
var body: some View { var body: some View {
HStack(spacing: 6) { NavigationStack {
Image(systemName: isHot ? "flame.fill" : "flame")
.foregroundColor(isHot ? .orange : .nightSecondary)
.scaleEffect(fireScale)
Text("\(streak) Nächte in Folge")
.font(.nightLabel(13, weight: isHot ? .semibold : .regular))
.foregroundColor(isHot ? .nightPrimary : .nightSecondary)
}
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(
ZStack { ZStack {
Capsule().fill(Color.nightRaised) EtherealBackground().ignoresSafeArea()
if isHot {
Capsule().fill(Color.orange.opacity(0.06)) ScrollView {
Capsule().strokeBorder(Color.orange.opacity(0.2), lineWidth: 1) 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)
} }
} }
) .navigationTitle("Bearbeiten")
.onAppear { .navigationBarTitleDisplayMode(.inline)
guard isHot else { return } .toolbar {
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { ToolbarItem(placement: .navigationBarLeading) {
fireScale = 1.18 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 ActionRow: View {
let icon: String
struct ProfileStat: View { let title: String
let value: Int let value: String
let label: String
@State private var displayValue: Int = 0
@State private var appeared = false
var body: some View { var body: some View {
VStack(spacing: 3) { HStack(spacing: 16) {
Text("\(displayValue)") Image(systemName: icon)
.font(.nightTitle(18)) .foregroundColor(.nightPurpleSoft)
.frame(width: 24)
Text(title)
.font(.nightBody(16))
.foregroundColor(.nightPrimary) .foregroundColor(.nightPrimary)
.contentTransition(.numericText(value: Double(displayValue)))
Text(label) Spacer()
.font(.nightLabel(12))
Text(value)
.font(.nightLabel(16, weight: .bold))
.foregroundColor(.nightSecondary) .foregroundColor(.nightSecondary)
} }
.onAppear { .padding(20)
guard !appeared else { return } .background(.ultraThinMaterial.opacity(0.3))
appeared = true .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
withAnimation(.easeOut(duration: 0.5).delay(0.3)) { .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(Color.white.opacity(0.05), lineWidth: 0.5))
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
}
}
} }
} }
+27 -7
View File
@@ -4,15 +4,35 @@ struct RootView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
var body: some View { var body: some View {
Group { ZStack {
if appState.isAuthenticated { // Background is global and strictly constrained
MainTabView() EtherealBackground()
.transition(.opacity.combined(with: .scale(scale: 0.98))) .ignoresSafeArea()
} else {
OnboardingView() Group {
.transition(.opacity.combined(with: .scale(scale: 1.02))) if appState.isAuthenticated {
MainTabView()
.frame(maxWidth: .infinity)
} else {
OnboardingView()
.frame(maxWidth: .infinity)
}
} }
} }
.animation(.spring(duration: 0.5, bounce: 0.15), value: appState.isAuthenticated) .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
}
} }
} }
+192 -204
View File
@@ -10,211 +10,183 @@ struct SettingsView: View {
@State private var deletePassword = "" @State private var deletePassword = ""
@State private var isDeleting = false @State private var isDeleting = false
@State private var deleteError: String? @State private var deleteError: String?
@StateObject private var settings = AppSettings.shared
@State private var notificationsEnabled = false @State private var notificationsEnabled = false
@State private var appeared = false @State private var appeared = false
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack { ZStack {
Color.nightBase.ignoresSafeArea() EtherealBackground().ignoresSafeArea()
ScrollView { ScrollView {
VStack(spacing: 20) { VStack(spacing: 24) {
// Account // Account Profile Header
SettingsSection { if let user = appState.currentUser {
if let user = appState.currentUser { VStack(spacing: 16) {
HStack(spacing: 12) { AvatarView(user: user, size: 80)
ZStack { .shadow(color: .black.opacity(0.15), radius: 15, y: 8)
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 VStack(spacing: 4) {
SettingsSection(header: "benachrichtigungen") { Text(user.displayName)
SettingsRow { .font(.nightTitle(24))
HStack {
SettingsIcon(icon: "bell.fill", color: .nightPurple)
VStack(alignment: .leading, spacing: 2) {
Text("nightly ping")
.font(.nightLabel(15))
.foregroundColor(.nightPrimary)
Text("Wenn das Fenster öffnet")
.font(.nightLabel(12))
.foregroundColor(.nightSecondary)
}
Spacer()
Toggle("", isOn: $notificationsEnabled)
.tint(.nightPurple)
.labelsHidden()
}
}
Divider().background(Color.nightBorder).padding(.leading, 52)
SettingsRow {
HStack(spacing: 8) {
Image(systemName: "info.circle")
.foregroundColor(.nightTertiary)
.font(.system(size: 13))
Text("Push-Benachrichtigungen benötigen einen bezahlten Apple Developer Account ($99/Jahr).")
.font(.nightLabel(12))
.foregroundColor(.nightTertiary)
.lineSpacing(3)
}
.padding(.vertical, 2)
}
}
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 12)
.animation(.spring(duration: 0.4, bounce: 0.15).delay(0.1), value: appeared)
// Rechtliches
SettingsSection(header: "rechtliches") {
SettingsRow {
Button {
showLegal = true
} label: {
HStack {
SettingsIcon(icon: "doc.text", color: .nightSecondary)
Text("Impressum & Datenschutz")
.font(.nightLabel(15))
.foregroundColor(.nightPrimary)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.nightTertiary)
}
}
}
Divider().background(Color.nightBorder).padding(.leading, 52)
SettingsRow {
HStack {
SettingsIcon(icon: "info.circle", color: .nightSecondary)
Text("Version")
.font(.nightLabel(15))
.foregroundColor(.nightPrimary) .foregroundColor(.nightPrimary)
Spacer() Text("@\(user.username)")
Text(appVersion) .font(.nightLabel(15))
.font(.nightMono(13)) .foregroundColor(.nightPurpleSoft)
.foregroundColor(.nightTertiary)
} }
} }
.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 // App Settings Sections
SettingsSection(header: "account") { VStack(spacing: 20) {
SettingsRow {
// 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 { Button {
Haptics.medium() Haptics.medium()
appState.signOut() appState.signOut()
dismiss() dismiss()
} label: { } label: {
HStack { Text("Abmelden")
SettingsIcon(icon: "rectangle.portrait.and.arrow.right", color: .nightSecondary) .font(.nightLabel(16, weight: .bold))
Text("abmelden") .foregroundColor(.nightSecondary)
.font(.nightLabel(15)) .frame(maxWidth: .infinity).frame(height: 54)
.foregroundColor(.nightPrimary) .background(RoundedRectangle(cornerRadius: 18, style: .continuous).fill(.white.opacity(0.05)))
Spacer()
}
} }
}
Divider().background(Color.nightBorder).padding(.leading, 52)
SettingsRow {
Button { Button {
Haptics.warning() Haptics.warning()
showDeleteConfirm = true showDeleteConfirm = true
} label: { } label: {
HStack { Text("Account löschen")
SettingsIcon(icon: "trash", color: .nightRed) .font(.nightLabel(14, weight: .medium))
Text("account löschen") .foregroundColor(.nightRed.opacity(0.8))
.font(.nightLabel(15))
.foregroundColor(.nightRed)
Spacer()
}
} }
.padding(.top, 8)
} }
.padding(.top, 12)
} }
.opacity(appeared ? 1 : 0) .opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 12) .offset(y: appeared ? 0 : 20)
.animation(.spring(duration: 0.4, bounce: 0.15).delay(0.2), value: appeared) .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(20)
.padding(.top, 8)
} }
} }
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
Text("einstellungen") Text("Einstellungen").font(.nightTitle(20)).foregroundColor(.nightPrimary)
.font(.nightTitle(17))
.foregroundColor(.nightPrimary)
} }
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarTrailing) {
Button("Fertig") { dismiss() } Button("Fertig") { dismiss() }
.foregroundColor(.nightSecondary) .font(.nightLabel(16, weight: .bold))
.foregroundColor(.nightPurpleSoft)
} }
} }
.sheet(isPresented: $showLegal) { LegalView() } .sheet(isPresented: $showLegal) { LegalView() }
} }
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
.confirmationDialog( .confirmationDialog("Account löschen?", isPresented: $showDeleteConfirm, titleVisibility: .visible) {
"Account wirklich löschen?", Button("Ja, endgültig löschen", role: .destructive) { showDeleteFinal = true }
isPresented: $showDeleteConfirm,
titleVisibility: .visible
) {
Button("Ja, Account löschen", role: .destructive) {
showDeleteFinal = true
}
} message: { } 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) { .sheet(isPresented: $showDeleteFinal) {
DeleteAccountSheet( DeleteAccountSheet(password: $deletePassword, isDeleting: isDeleting, error: deleteError, onDelete: { Task { await deleteAccount() } })
password: $deletePassword,
isDeleting: isDeleting,
error: deleteError,
onDelete: { Task { await deleteAccount() } }
)
} }
.onAppear { .onAppear {
checkNotificationStatus() checkNotificationStatus()
appeared = true withAnimation { appeared = true }
} }
} }
var appVersion: String { var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" }
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
}
func checkNotificationStatus() { func checkNotificationStatus() {
Task { Task {
let settings = await UNUserNotificationCenter.current().notificationSettings() let settings = await UNUserNotificationCenter.current().notificationSettings()
notificationsEnabled = settings.authorizationStatus == .authorized notificationsEnabled = settings.authorizationStatus == .authorized
} }
} }
func deleteAccount() async { func deleteAccount() async {
isDeleting = true isDeleting = true
deleteError = nil deleteError = nil
@@ -223,66 +195,88 @@ struct SettingsView: View {
try await appState.deleteAccount() try await appState.deleteAccount()
showDeleteFinal = false showDeleteFinal = false
dismiss() dismiss()
} catch { } catch { deleteError = error.localizedDescription }
deleteError = error.localizedDescription }
}
// MARK: - Refined Settings Components
struct SettingsSection<Content: View>: 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 SettingsToggleRow: View {
let icon: String
struct SettingsSection<Content: View>: View { let color: Color
var header: String? = nil let title: String
@ViewBuilder let content: Content @Binding var isOn: Bool
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { SettingsRow {
if let header { HStack {
Text(header) SettingsIcon(icon: icon, color: color)
.font(.nightLabel(12, weight: .semibold)) Text(title).font(.nightLabel(15)).foregroundColor(.nightPrimary)
.foregroundColor(.nightSecondary) Spacer()
.kerning(0.5) Toggle("", isOn: $isOn).tint(.nightPurpleSoft).labelsHidden()
.padding(.leading, 4)
.padding(.bottom, 8)
} }
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<Content: View>: View { struct SettingsRow<Content: View>: View {
@ViewBuilder let content: Content @ViewBuilder let content: Content
var body: some View { var body: some View {
content content.padding(.horizontal, 16).padding(.vertical, 14)
.padding(.horizontal, 16)
.padding(.vertical, 13)
} }
} }
struct SettingsIcon: View { struct SettingsIcon: View {
let icon: String let icon: String
let color: Color let color: Color
var body: some View { var body: some View {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 13, weight: .medium)) .font(.system(size: 13, weight: .bold))
.foregroundColor(color) .foregroundColor(color)
.frame(width: 28, height: 28) .frame(width: 32, height: 32)
.background(color.opacity(0.1)) .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 { struct DeleteAccountSheet: View {
@Binding var password: String @Binding var password: String
@@ -294,24 +288,21 @@ struct DeleteAccountSheet: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack { ZStack {
Color.nightBase.ignoresSafeArea() EtherealBackground().ignoresSafeArea()
VStack(spacing: 24) { VStack(spacing: 32) {
Image(systemName: "trash.circle.fill") VStack(spacing: 16) {
.font(.system(size: 52)) Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.nightRed) .font(.system(size: 50))
.foregroundColor(.nightRed)
VStack(spacing: 8) { VStack(spacing: 8) {
Text("Account löschen") Text("Account löschen").font(.nightTitle(24)).foregroundColor(.nightPrimary)
.font(.nightTitle(22)) Text("Bestätige dein Passwort, um alle deine Daten dauerhaft zu löschen.").font(.nightBody(16)).foregroundColor(.nightSecondary).multilineTextAlignment(.center)
.foregroundColor(.nightPrimary) }
Text("Diese Aktion löscht alle deine Daten dauerhaft. Kein Weg zurück.")
.font(.nightBody(15))
.foregroundColor(.nightSecondary)
.multilineTextAlignment(.center)
} }
.padding(.top, 40)
NightlyField(text: $password, placeholder: "passwort bestätigen", icon: "lock", isSecure: true) NightlyField(text: $password, placeholder: "Dein Passwort", icon: "lock", isSecure: true)
.padding(.horizontal, 24)
if let err = error { if let err = error {
Text(err).font(.nightLabel(13)).foregroundColor(.nightRed) Text(err).font(.nightLabel(13)).foregroundColor(.nightRed)
@@ -321,18 +312,16 @@ struct DeleteAccountSheet: View {
Haptics.warning() Haptics.warning()
onDelete() onDelete()
} label: { } label: {
Group { if isDeleting { ProgressView().tint(.white) }
if isDeleting { ProgressView().tint(.white) } else { Text("Endgültig löschen").font(.nightLabel(17, weight: .bold)) }
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))
} }
.buttonStyle(NightlyPrimaryButton())
.tint(.nightRed)
.disabled(password.isEmpty || isDeleting) .disabled(password.isEmpty || isDeleting)
.padding(.horizontal, 24)
Spacer()
} }
.padding(.top, 32) .padding(28)
} }
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
@@ -341,6 +330,5 @@ struct DeleteAccountSheet: View {
} }
} }
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
.presentationDetents([.medium])
} }
} }
@@ -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
)
}
}
}
}
}
@@ -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.")
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"xcode": {
"type": "local",
"command": ["npx", "-y", "xpresso-mcp"],
"enabled": true
}
}
}