Compare commits
3 Commits
8959485964
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e4f99255bd | |||
| 7e910ce23d | |||
| 0d4b69713d |
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"xcode-tools": {
|
||||||
|
"command": "xcrun",
|
||||||
|
"args": [
|
||||||
|
"mcpbridge"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"xcode": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "xcode-mcp-server"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"xcode-tools": {
|
||||||
|
"command": "xcrun",
|
||||||
|
"args": [
|
||||||
|
"mcpbridge"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"xcode-tools": {
|
||||||
|
"command": "xcrun",
|
||||||
|
"args": [
|
||||||
|
"mcpbridge"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 |
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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") {
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"mcp": {
|
||||||
|
"xcode": {
|
||||||
|
"type": "local",
|
||||||
|
"command": ["npx", "-y", "xpresso-mcp"],
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user