commit 5bc81d5b3b119bfbfca28691fb3865bcdf9aac0b Author: denshooter Date: Thu Apr 23 23:31:38 2026 +0200 Initial commit: nightly iOS app + Supabase backend iOS SwiftUI app with Supabase auth/realtime, Node.js backend, Docker/Supabase self-hosted infrastructure, and APNs scheduler. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b54dd8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Xcode +*.xcuserstate +xcuserdata/ +*.moved-aside +DerivedData/ +*.pbxuser +*.mode1v2 +*.mode2v2 +*.perspectivefile + +# Xcode secrets (enthält echte Credentials) +ios/thoughts/thoughts/Config.xcconfig + +# Swift Package Manager +.build/ +.swiftpm/ + +# CocoaPods +Pods/ + +# Node +node_modules/ +npm-debug.log* + +# Docker / Server +infrastructure/volumes/ +.env +!.env.example +!.env.nightly + +# Apple Push Notification Certs +*.p8 +*.pem + +# Logs +*.log diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..131a90c --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,8 @@ +DB_PASSWORD=change_me_use_a_strong_password +JWT_SECRET=change_me_use_a_very_long_random_string_min_64_chars + +# Apple Push Notifications +APNS_KEY_PATH=/app/certs/AuthKey_XXXXXXXX.p8 +APNS_KEY_ID=XXXXXXXXXX +APNS_TEAM_ID=XXXXXXXXXX +APNS_BUNDLE_ID=app.nightly diff --git a/backend/Caddyfile b/backend/Caddyfile new file mode 100644 index 0000000..53ef262 --- /dev/null +++ b/backend/Caddyfile @@ -0,0 +1,6 @@ +# Replace with your actual domain +api.nightly.app { + reverse_proxy api:3000 { + header_up X-Real-IP {remote_host} + } +} diff --git a/backend/api/Dockerfile b/backend/api/Dockerfile new file mode 100644 index 0000000..736ba1c --- /dev/null +++ b/backend/api/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY src ./src +EXPOSE 3000 +CMD ["node", "src/index.js"] diff --git a/backend/api/package.json b/backend/api/package.json new file mode 100644 index 0000000..5402f08 --- /dev/null +++ b/backend/api/package.json @@ -0,0 +1,19 @@ +{ + "name": "nightly-api", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js" + }, + "dependencies": { + "@node-rs/bcrypt": "^1.10.4", + "express": "^4.21.2", + "jsonwebtoken": "^9.0.2", + "node-apn": "^3.0.0", + "pg": "^8.13.3", + "redis": "^4.7.0", + "sharp": "^0.33.5", + "uuid": "^11.1.0" + } +} diff --git a/backend/api/src/db/schema.sql b/backend/api/src/db/schema.sql new file mode 100644 index 0000000..9137e85 --- /dev/null +++ b/backend/api/src/db/schema.sql @@ -0,0 +1,89 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- ── Users ────────────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + username TEXT UNIQUE NOT NULL CHECK (username ~ '^[a-z0-9_.]{3,20}$'), + display_name TEXT NOT NULL, + bio TEXT, + avatar_url TEXT, + push_token TEXT, + password_hash TEXT NOT NULL, + is_pro BOOLEAN DEFAULT FALSE, + is_admin BOOLEAN DEFAULT FALSE, + anon_slots_used INT DEFAULT 0, + anon_slots_reset_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ── Posts ────────────────────────────────────────────────────────────────── + +CREATE TYPE post_mood AS ENUM ('still', 'unruhig', 'melancholisch', 'aufgedreht'); +CREATE TYPE review_status AS ENUM ('ok', 'pending', 'urgent', 'hidden', 'approved', 'removed'); + +CREATE TABLE IF NOT EXISTS posts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL CHECK (LENGTH(TRIM(content)) BETWEEN 1 AND 280), + mood post_mood, + is_anonymous BOOLEAN DEFAULT FALSE, + review_status review_status DEFAULT 'ok', + hidden_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, -- soft delete: bleibt im persönlichen Tagebuch + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id); +CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_posts_review ON posts(review_status) WHERE review_status != 'ok'; + +-- ── Resonances ("Hat mich getroffen") ───────────────────────────────────── + +CREATE TABLE IF NOT EXISTS resonances ( + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (post_id, user_id) +); + +-- ── Whispers (private, einmalig, keine Antwort) ─────────────────────────── + +CREATE TABLE IF NOT EXISTS whispers ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + from_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + to_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + post_id UUID REFERENCES posts(id) ON DELETE SET NULL, + content TEXT NOT NULL CHECK (LENGTH(TRIM(content)) BETWEEN 1 AND 280), + read_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ── Follows ──────────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS follows ( + follower_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + following_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (follower_id, following_id) +); + +-- ── Reports (Moderation) ─────────────────────────────────────────────────── + +CREATE TYPE report_reason AS ENUM ( + 'hate', 'harassment', 'selfharm', 'illegal', 'spam', 'other' +); + +CREATE TABLE IF NOT EXISTS reports ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + reason report_reason NOT NULL, + details TEXT, + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE (post_id, reporter_id) -- ein User kann denselben Post nur einmal melden +); + +CREATE INDEX IF NOT EXISTS idx_reports_post_id ON reports(post_id); +CREATE INDEX IF NOT EXISTS idx_reports_unresolved ON reports(created_at) WHERE resolved_at IS NULL; diff --git a/backend/api/src/index.js b/backend/api/src/index.js new file mode 100644 index 0000000..6011120 --- /dev/null +++ b/backend/api/src/index.js @@ -0,0 +1,31 @@ +import express from 'express' +import { createClient } from 'redis' +import pg from 'pg' +import authRouter from './routes/auth.js' +import postsRouter from './routes/posts.js' +import usersRouter from './routes/users.js' +import moderationRouter from './routes/moderation.js' +import { schedulePing } from './scheduler.js' + +const app = express() +app.use(express.json({ limit: '12mb' })) + +// Postgres +export const db = new pg.Pool({ connectionString: process.env.DATABASE_URL }) + +// Redis +export const redis = createClient({ url: process.env.REDIS_URL }) +await redis.connect() +redis.on('error', err => console.error('Redis error:', err)) + +// Routes +app.use('/auth', authRouter) +app.use('/posts', postsRouter) +app.use('/users', usersRouter) +app.use('/reports', moderationRouter) +app.get('/health', (_, res) => res.json({ ok: true })) + +// Start nightly ping scheduler +schedulePing() + +app.listen(3000, () => console.log('nightly API running on :3000')) diff --git a/backend/api/src/middleware/auth.js b/backend/api/src/middleware/auth.js new file mode 100644 index 0000000..c71f9d6 --- /dev/null +++ b/backend/api/src/middleware/auth.js @@ -0,0 +1,14 @@ +import jwt from 'jsonwebtoken' + +export function authenticate(req, res, next) { + const header = req.headers.authorization + if (!header?.startsWith('Bearer ')) { + return res.status(401).json({ message: 'Nicht autorisiert' }) + } + try { + req.user = jwt.verify(header.slice(7), process.env.JWT_SECRET) + next() + } catch { + res.status(401).json({ message: 'Token ungültig oder abgelaufen' }) + } +} diff --git a/backend/api/src/push.js b/backend/api/src/push.js new file mode 100644 index 0000000..73cb0d0 --- /dev/null +++ b/backend/api/src/push.js @@ -0,0 +1,32 @@ +import apn from 'node-apn' + +let provider = null + +function getProvider() { + if (provider) return provider + if (!process.env.APNS_KEY_PATH) return null + + provider = new apn.Provider({ + token: { + key: process.env.APNS_KEY_PATH, + keyId: process.env.APNS_KEY_ID, + teamId: process.env.APNS_TEAM_ID, + }, + production: process.env.NODE_ENV === 'production' + }) + return provider +} + +export async function sendPushToToken(token, { title, body }) { + const p = getProvider() + if (!p) return + + const note = new apn.Notification() + note.expiry = Math.floor(Date.now() / 1000) + 3600 + note.badge = 1 + note.sound = 'default' + note.alert = { title, body } + note.topic = process.env.APNS_BUNDLE_ID + + await p.send(note, token) +} diff --git a/backend/api/src/routes/auth.js b/backend/api/src/routes/auth.js new file mode 100644 index 0000000..2288b11 --- /dev/null +++ b/backend/api/src/routes/auth.js @@ -0,0 +1,60 @@ +import { Router } from 'express' +import { hash, verify } from '@node-rs/bcrypt' +import jwt from 'jsonwebtoken' +import { v4 as uuid } from 'uuid' +import { db } from '../index.js' + +const router = Router() + +router.post('/register', async (req, res) => { + const { username, password, displayName } = req.body ?? {} + + if (!username || !password || !displayName) { + return res.status(400).json({ message: 'Alle Felder erforderlich' }) + } + if (!/^[a-z0-9_.]{3,20}$/.test(username)) { + return res.status(400).json({ message: 'Benutzername: 3–20 Zeichen, nur a-z 0-9 _ .' }) + } + if (password.length < 8) { + return res.status(400).json({ message: 'Passwort muss mindestens 8 Zeichen haben' }) + } + + try { + const { rows } = await db.query('SELECT id FROM users WHERE username = $1', [username]) + if (rows.length > 0) return res.status(409).json({ message: 'Benutzername bereits vergeben' }) + + const passwordHash = await hash(password, 12) + const id = uuid() + await db.query( + 'INSERT INTO users (id, username, display_name, password_hash) VALUES ($1,$2,$3,$4)', + [id, username, displayName, passwordHash] + ) + + const token = jwt.sign({ userId: id }, process.env.JWT_SECRET, { expiresIn: '90d' }) + res.status(201).json({ token }) + } catch (err) { + console.error('register:', err) + res.status(500).json({ message: 'Serverfehler' }) + } +}) + +router.post('/login', async (req, res) => { + const { username, password } = req.body ?? {} + + try { + const { rows } = await db.query('SELECT * FROM users WHERE username = $1', [username]) + const user = rows[0] + + if (!user || !await verify(password, user.password_hash)) { + return res.status(401).json({ message: 'Benutzername oder Passwort falsch' }) + } + + const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: '90d' }) + res.json({ token }) + } catch (err) { + console.error('login:', err) + res.status(500).json({ message: 'Serverfehler' }) + } +}) + +export default router diff --git a/backend/api/src/routes/moderation.js b/backend/api/src/routes/moderation.js new file mode 100644 index 0000000..c1924a8 --- /dev/null +++ b/backend/api/src/routes/moderation.js @@ -0,0 +1,110 @@ +import { Router } from 'express' +import { v4 as uuid } from 'uuid' +import { db } from '../index.js' +import { authenticate } from '../middleware/auth.js' + +const router = Router() +router.use(authenticate) + +const VALID_REASONS = new Set([ + 'hate', 'harassment', 'selfharm', 'illegal', 'spam', 'other' +]) + +// POST /reports — User meldet einen Post +router.post('/', async (req, res) => { + const { postId, reason, details } = req.body ?? {} + + if (!postId || !VALID_REASONS.has(reason)) { + return res.status(400).json({ message: 'Pflichtfelder fehlen' }) + } + + // Duplicate check: ein User kann denselben Post nur einmal melden + const { rows: existing } = await db.query( + 'SELECT id FROM reports WHERE post_id = $1 AND reporter_id = $2', + [postId, req.user.userId] + ) + if (existing.length > 0) { + return res.status(409).json({ message: 'Bereits gemeldet' }) + } + + await db.query(` + INSERT INTO reports (id, post_id, reporter_id, reason, details) + VALUES ($1, $2, $3, $4, $5) + `, [uuid(), postId, req.user.userId, reason, details?.slice(0, 500) ?? null]) + + // Bei kritischen Inhalten (Selbstverletzung) sofort flaggen + if (reason === 'selfharm' || reason === 'illegal') { + await db.query( + "UPDATE posts SET review_status = 'urgent' WHERE id = $1", + [postId] + ) + } + + // Automatisch ausblenden wenn >= 5 Reports (Schwelle anpassbar) + const { rows: [{ count }] } = await db.query( + 'SELECT COUNT(*) FROM reports WHERE post_id = $1', [postId] + ) + if (parseInt(count) >= 5) { + await db.query( + "UPDATE posts SET review_status = 'hidden', hidden_at = NOW() WHERE id = $1 AND review_status != 'approved'", + [postId] + ) + } + + res.status(201).json({ ok: true }) +}) + +// GET /reports/queue — Admin: offene Reports (nur für Admins) +router.get('/queue', requireAdmin, async (req, res) => { + const { rows } = await db.query(` + SELECT + p.id AS post_id, p.content, p.review_status, p.created_at AS post_created_at, + u.username AS author_username, p.is_anonymous, + COUNT(r.id)::int AS report_count, + ARRAY_AGG(DISTINCT r.reason) AS reasons, + MIN(r.created_at) AS first_reported_at + FROM posts p + JOIN users u ON u.id = p.user_id + JOIN reports r ON r.post_id = p.id + WHERE p.review_status IN ('pending', 'urgent', 'hidden') + GROUP BY p.id, u.username + ORDER BY + CASE p.review_status WHEN 'urgent' THEN 0 WHEN 'hidden' THEN 1 ELSE 2 END, + report_count DESC + LIMIT 100 + `) + res.json(rows) +}) + +// PATCH /reports/posts/:id — Admin: Post genehmigen oder löschen +router.patch('/posts/:id', requireAdmin, async (req, res) => { + const { action } = req.body // 'approve' | 'remove' | 'warn' + + if (!['approve', 'remove', 'warn'].includes(action)) { + return res.status(400).json({ message: 'Ungültige Aktion' }) + } + + if (action === 'approve') { + await db.query( + "UPDATE posts SET review_status = 'approved', hidden_at = NULL WHERE id = $1", + [req.params.id] + ) + } else if (action === 'remove') { + await db.query( + "UPDATE posts SET review_status = 'removed', deleted_at = NOW() WHERE id = $1", + [req.params.id] + ) + // Optional: User verwarnen + } + + res.json({ ok: true }) +}) + +function requireAdmin(req, res, next) { + if (!req.user?.isAdmin) { + return res.status(403).json({ message: 'Nicht erlaubt' }) + } + next() +} + +export default router diff --git a/backend/api/src/routes/posts.js b/backend/api/src/routes/posts.js new file mode 100644 index 0000000..16a6d21 --- /dev/null +++ b/backend/api/src/routes/posts.js @@ -0,0 +1,191 @@ +import { Router } from 'express' +import { v4 as uuid } from 'uuid' +import { db } from '../index.js' +import { authenticate } from '../middleware/auth.js' + +const router = Router() +router.use(authenticate) + +const VALID_MOODS = new Set(['still', 'unruhig', 'melancholisch', 'aufgedreht']) +const ANON_SLOTS_PER_NIGHT = 3 + +// GET /posts/feed +router.get('/feed', async (req, res) => { + try { + const { rows } = await db.query(` + SELECT + p.id, p.content, p.mood, p.is_anonymous, p.created_at, + -- Hide author info for anonymous posts (unless it's your own) + CASE WHEN p.is_anonymous AND p.user_id != $1 THEN NULL ELSE p.user_id END AS author_id, + CASE WHEN p.is_anonymous AND p.user_id != $1 THEN NULL ELSE u.username END AS username, + CASE WHEN p.is_anonymous AND p.user_id != $1 THEN NULL ELSE u.display_name END AS display_name, + CASE WHEN p.is_anonymous AND p.user_id != $1 THEN NULL ELSE u.avatar_url END AS avatar_url, + COUNT(r.user_id)::int AS resonance_count, + EXISTS(SELECT 1 FROM resonances WHERE post_id = p.id AND user_id = $1) AS has_resonated, + EXISTS(SELECT 1 FROM follows WHERE follower_id = $1 AND following_id = u.id) AS is_following + FROM posts p + JOIN users u ON u.id = p.user_id + LEFT JOIN resonances r ON r.post_id = p.id + WHERE p.created_at > NOW() - INTERVAL '14 hours' + AND p.deleted_at IS NULL + AND ( + p.user_id = $1 + OR p.user_id IN (SELECT following_id FROM follows WHERE follower_id = $1) + ) + GROUP BY p.id, u.id + ORDER BY p.created_at DESC + LIMIT 100 + `, [req.user.userId]) + + res.json(rows.map(formatPost)) + } catch (err) { + console.error('feed:', err) + res.status(500).json({ message: 'Serverfehler' }) + } +}) + +// POST /posts +router.post('/', async (req, res) => { + const { content, mood, isAnonymous } = req.body ?? {} + + if (!content?.trim()) return res.status(400).json({ message: 'Inhalt fehlt' }) + if (content.length > 280) return res.status(400).json({ message: 'Maximal 280 Zeichen' }) + if (!VALID_MOODS.has(mood)) return res.status(400).json({ message: 'Ungültige Stimmung' }) + + // One post per night window + const { rows: existing } = await db.query(` + SELECT id FROM posts + WHERE user_id = $1 + AND created_at > NOW() - INTERVAL '14 hours' + AND deleted_at IS NULL + `, [req.user.userId]) + + if (existing.length > 0) { + return res.status(409).json({ message: 'Du hast heute Nacht schon gepostet' }) + } + + // Check anonymous slots + if (isAnonymous) { + const { rows: [user] } = await db.query( + 'SELECT anon_slots_used, anon_slots_reset_at FROM users WHERE id = $1', + [req.user.userId] + ) + const resetDate = user?.anon_slots_reset_at + const slotsUsed = resetDate && new Date(resetDate) > new Date(Date.now() - 24 * 3600 * 1000) + ? (user.anon_slots_used ?? 0) : 0 + + if (slotsUsed >= ANON_SLOTS_PER_NIGHT) { + return res.status(403).json({ message: `Maximale Anonym-Posts (${ANON_SLOTS_PER_NIGHT}) für heute erreicht` }) + } + + await db.query( + 'UPDATE users SET anon_slots_used = $1, anon_slots_reset_at = NOW() WHERE id = $2', + [slotsUsed + 1, req.user.userId] + ) + } + + try { + const id = uuid() + await db.query( + 'INSERT INTO posts (id, user_id, content, mood, is_anonymous) VALUES ($1,$2,$3,$4,$5)', + [id, req.user.userId, content.trim(), mood, !!isAnonymous] + ) + res.status(201).json({ id }) + } catch (err) { + console.error('createPost:', err) + res.status(500).json({ message: 'Serverfehler' }) + } +}) + +// POST /posts/:id/resonate — toggle resonance +router.post('/:id/resonate', async (req, res) => { + const { id } = req.params + const userId = req.user.userId + + const { rows: [existing] } = await db.query( + 'SELECT 1 FROM resonances WHERE post_id = $1 AND user_id = $2', + [id, userId] + ) + + if (existing) { + await db.query('DELETE FROM resonances WHERE post_id = $1 AND user_id = $2', [id, userId]) + } else { + await db.query( + 'INSERT INTO resonances (post_id, user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', + [id, userId] + ) + // Notify post author if it's not their own post + await notifyResonance(id, userId) + } + + const { rows: [{ count }] } = await db.query( + 'SELECT COUNT(*) FROM resonances WHERE post_id = $1', [id] + ) + res.json({ resonanceCount: parseInt(count), hasResonated: !existing }) +}) + +// DELETE /posts/:id/resonate — explicit unresoante +router.delete('/:id/resonate', async (req, res) => { + await db.query( + 'DELETE FROM resonances WHERE post_id = $1 AND user_id = $2', + [req.params.id, req.user.userId] + ) + res.json({ ok: true }) +}) + +// Helper: send push when someone resonates with your post +async function notifyResonance(postId, fromUserId) { + try { + const { rows: [post] } = await db.query( + 'SELECT user_id, is_anonymous FROM posts WHERE id = $1', [postId] + ) + if (!post || post.user_id === fromUserId) return + + const { rows: [author] } = await db.query( + 'SELECT push_token FROM users WHERE id = $1', [post.user_id] + ) + if (!author?.push_token) return + + const { rows: [{ count }] } = await db.query( + 'SELECT COUNT(*) FROM resonances WHERE post_id = $1', [postId] + ) + const cnt = parseInt(count) + // Only notify at milestones to avoid spam + if (![1, 5, 10, 25, 50].includes(cnt)) return + + // Import and send push — lazy import to avoid circular deps + const { sendPushToToken } = await import('../push.js') + await sendPushToToken(author.push_token, { + title: 'nightly', + body: cnt === 1 + ? 'Jemand wurde von deinem Gedanken getroffen 🫀' + : `${cnt} Menschen wurden von deinem Gedanken getroffen 🫀` + }) + } catch (_) { + // Non-critical + } +} + +function formatPost(row) { + return { + id: row.id, + content: row.content, + mood: row.mood ?? null, + isAnonymous: row.is_anonymous, + createdAt: row.created_at, + nightOf: row.created_at, + resonanceCount: row.resonance_count, + hasResonated: row.has_resonated, + commentCount: 0, + author: row.author_id ? { + id: row.author_id, + username: row.username, + displayName: row.display_name, + avatarURL: row.avatar_url, + followerCount: 0, followingCount: 0, postCount: 0, + isFollowing: row.is_following + } : null + } +} + +export default router diff --git a/backend/api/src/routes/users.js b/backend/api/src/routes/users.js new file mode 100644 index 0000000..b518d46 --- /dev/null +++ b/backend/api/src/routes/users.js @@ -0,0 +1,82 @@ +import { Router } from 'express' +import { db } from '../index.js' +import { authenticate } from '../middleware/auth.js' + +const router = Router() +router.use(authenticate) + +router.get('/me', async (req, res) => { + const { rows } = await db.query('SELECT * FROM users WHERE id = $1', [req.user.userId]) + if (!rows[0]) return res.status(404).json({ message: 'Nicht gefunden' }) + res.json(formatUser(rows[0])) +}) + +router.get('/:id/posts', async (req, res) => { + const { rows } = await db.query(` + SELECT p.*, u.username, u.display_name, u.avatar_url + FROM posts p JOIN users u ON u.id = p.user_id + WHERE p.user_id = $1 ORDER BY p.created_at DESC LIMIT 50 + `, [req.params.id]) + res.json(rows.map(row => ({ + id: row.id, content: row.content, imageURL: row.image_url, + createdAt: row.created_at, nightOf: row.created_at, + reactions: {}, myReaction: null, commentCount: 0, + author: { + id: row.user_id, username: row.username, displayName: row.display_name, + avatarURL: row.avatar_url, followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false + } + }))) +}) + +router.get('/:id/streak', async (req, res) => { + // Count consecutive nights (window = 2–5 AM, normalized to "day before 2 AM") + const { rows } = await db.query(` + WITH nights AS ( + SELECT DISTINCT DATE(created_at - INTERVAL '2 hours') AS night + FROM posts WHERE user_id = $1 + ORDER BY night DESC + ), + numbered AS ( + SELECT night, ROW_NUMBER() OVER (ORDER BY night DESC) AS rn FROM nights + ) + SELECT COUNT(*) AS streak FROM numbered + WHERE night = (CURRENT_DATE - (rn - 1) * INTERVAL '1 day') + `, [req.params.id]) + res.json({ streak: parseInt(rows[0]?.streak ?? 0) }) +}) + +router.post('/me/push-token', async (req, res) => { + const { token } = req.body ?? {} + if (!token) return res.status(400).json({ message: 'Token fehlt' }) + await db.query('UPDATE users SET push_token = $1 WHERE id = $2', [token, req.user.userId]) + res.json({ ok: true }) +}) + +router.post('/:id/follow', async (req, res) => { + if (req.params.id === req.user.userId) { + return res.status(400).json({ message: 'Du kannst dir selbst nicht folgen' }) + } + await db.query( + 'INSERT INTO follows (follower_id, following_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', + [req.user.userId, req.params.id] + ) + res.json({ ok: true }) +}) + +router.delete('/:id/follow', async (req, res) => { + await db.query( + 'DELETE FROM follows WHERE follower_id = $1 AND following_id = $2', + [req.user.userId, req.params.id] + ) + res.json({ ok: true }) +}) + +function formatUser(u) { + return { + id: u.id, username: u.username, displayName: u.display_name, + bio: u.bio, avatarURL: u.avatar_url, + followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false + } +} + +export default router diff --git a/backend/api/src/scheduler.js b/backend/api/src/scheduler.js new file mode 100644 index 0000000..34c6b7f --- /dev/null +++ b/backend/api/src/scheduler.js @@ -0,0 +1,96 @@ +import apn from 'node-apn' +import { db, redis } from './index.js' + +const REDIS_PING_KEY = 'nightly:ping_time' + +let apnProvider = null + +function initAPNs() { + if (!process.env.APNS_KEY_PATH) { + console.warn('APNs not configured — push notifications disabled') + return null + } + return new apn.Provider({ + token: { + key: process.env.APNS_KEY_PATH, + keyId: process.env.APNS_KEY_ID, + teamId: process.env.APNS_TEAM_ID, + }, + production: process.env.NODE_ENV === 'production' + }) +} + +// Pick a random minute for tonight's ping (between 02:00 and 04:30) +// Store in Redis so all instances agree +async function getTonightsPingTime() { + const stored = await redis.get(REDIS_PING_KEY) + if (stored) return JSON.parse(stored) + + const hour = 2 + Math.floor(Math.random() * 2) // 2 or 3 AM + const minute = Math.floor(Math.random() * 60) + const pingTime = { hour, minute } + + // Expire at 6 AM (well past window) + const ttl = secondsUntil(6, 0) + await redis.set(REDIS_PING_KEY, JSON.stringify(pingTime), { EX: ttl }) + return pingTime +} + +function secondsUntil(targetHour, targetMinute) { + const now = new Date() + const target = new Date(now) + target.setHours(targetHour, targetMinute, 0, 0) + if (target <= now) target.setDate(target.getDate() + 1) + return Math.floor((target - now) / 1000) +} + +export function schedulePing() { + apnProvider = initAPNs() + + // Check every minute + setInterval(async () => { + const { hour, minute } = await getTonightsPingTime() + const now = new Date() + + if (now.getHours() === hour && now.getMinutes() === minute) { + await sendPing() + } + }, 60_000) +} + +async function sendPing() { + if (!apnProvider) return + + const { rows } = await db.query( + 'SELECT push_token FROM users WHERE push_token IS NOT NULL' + ) + if (rows.length === 0) return + + const messages = [ + { title: 'nightly', body: 'Das Fenster ist offen. Was geht dir durch den Kopf? 🌙' }, + { title: 'nightly', body: 'Alle anderen sind auch wach. Was beschäftigt dich gerade?' }, + { title: 'nightly', body: '3 Stunden. Kein Filter. Nur du und deine Gedanken.' }, + { title: 'nightly', body: 'Was würdest du sagen, wenn niemand es sehen würde?' }, + ] + const msg = messages[Math.floor(Math.random() * messages.length)] + + let sent = 0 + for (const { push_token } of rows) { + const note = new apn.Notification() + note.expiry = Math.floor(Date.now() / 1000) + 3 * 3600 + note.badge = 1 + note.sound = 'default' + note.alert = msg + note.topic = process.env.APNS_BUNDLE_ID + note.payload = { type: 'nightly_ping' } + + try { + await apnProvider.send(note, push_token) + sent++ + } catch (err) { + console.error(`APNs send failed for token ${push_token}:`, err.message) + } + } + + console.log(`[scheduler] Ping sent to ${sent}/${rows.length} users`) +} diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..6574bf2 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,66 @@ +services: + api: + build: ./api + restart: unless-stopped + env_file: .env + environment: + DATABASE_URL: postgres://nightly:${DB_PASSWORD}@db:5432/nightly + REDIS_URL: redis://redis:6379 + NODE_ENV: production + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + networks: + - internal + + db: + image: postgres:16-alpine + restart: unless-stopped + volumes: + - postgres_data:/var/lib/postgresql/data + - ./api/src/db/schema.sql:/docker-entrypoint-initdb.d/01_schema.sql + environment: + POSTGRES_DB: nightly + POSTGRES_USER: nightly + POSTGRES_PASSWORD: ${DB_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U nightly"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - internal + + redis: + image: redis:7-alpine + restart: unless-stopped + volumes: + - redis_data:/data + networks: + - internal + + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile + - caddy_data:/data + - caddy_config:/config + networks: + - internal + +volumes: + postgres_data: + redis_data: + caddy_data: + caddy_config: + +networks: + internal: + driver: bridge diff --git a/infrastructure/.env.nightly b/infrastructure/.env.nightly new file mode 100644 index 0000000..0fee2ed --- /dev/null +++ b/infrastructure/.env.nightly @@ -0,0 +1,15 @@ +# nightly-spezifische Umgebungsvariablen +# (Supabase-eigene Variablen stehen in .env) + +# APNs — Apple Push Notifications +# ⚠️ Erfordert bezahlten Apple Developer Account ($99/Jahr) +# Ohne Account: diese Zeilen auskommentiert lassen +# APNS_KEY_PATH=/app/certs/AuthKey_XXXXXXXXXX.p8 +# APNS_KEY_ID=XXXXXXXXXX +# APNS_TEAM_ID=XXXXXXXXXX +APNS_BUNDLE_ID=app.xxx # Ersetze mit deiner Bundle-ID, z.B. app.nightly + +# App-Konfiguration +WINDOW_START_HOUR=2 # Fenster öffnet 02:00 Uhr +WINDOW_END_HOUR=5 # Fenster schließt 05:00 Uhr +ANON_SLOTS_PER_NIGHT=3 # Anonyme Posts pro User pro Nacht diff --git a/infrastructure/Caddyfile b/infrastructure/Caddyfile new file mode 100644 index 0000000..190653b --- /dev/null +++ b/infrastructure/Caddyfile @@ -0,0 +1,19 @@ +# Ersetze xxx mit deinem App-Namen +# z.B. api.nightly.dk0.dev + +# Supabase API (PostgREST, Auth, Storage, Realtime) +api.xxx.dk0.dev { + reverse_proxy kong:8000 { + header_up Host {host} + header_up X-Real-IP {remote_host} + } +} + +# Supabase Studio (Admin-Dashboard) +# ⚠️ Zugriff absichern! Nur von bekannten IPs erlauben (optional). +studio.xxx.dk0.dev { + reverse_proxy studio:3000 + # IP-Whitelist (optional, empfohlen): + # @blocked not remote_ip 1.2.3.4 + # respond @blocked 403 +} diff --git a/infrastructure/docker-compose.override.yml b/infrastructure/docker-compose.override.yml new file mode 100644 index 0000000..8ec6361 --- /dev/null +++ b/infrastructure/docker-compose.override.yml @@ -0,0 +1,43 @@ +# Ergänzungen zum offiziellen Supabase docker-compose.yml +# Start: docker compose -p nightly -f docker-compose.yml -f docker-compose.override.yml up -d + +services: + + # Schema-Migration wird beim ersten Postgres-Start eingespielt + db: + volumes: + - ./volumes/db/data:/var/lib/postgresql/data:Z + - ../nightly-migrations/001_schema.sql:/docker-entrypoint-initdb.d/99_nightly.sql:ro + + # Kong (Supabase API-Gateway) → im proxy-Netzwerk für NPM + kong: + networks: + - default + - proxy + + # Studio (Admin-Dashboard) → im proxy-Netzwerk für NPM + studio: + networks: + - default + - proxy + + # Nightly Scheduler: APNs-Ping + Resonance-Milestones + # ⚠️ APNs: erfordert bezahlten Apple Developer Account ($99/Jahr) + scheduler: + build: ./scheduler + restart: unless-stopped + env_file: + - .env.nightly + environment: + SUPABASE_URL: http://kong:8000 + SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} + depends_on: + - kong + networks: + - default + +networks: + # Externes Netzwerk das NPM und Supabase teilen + # Anlegen mit: docker network create proxy + proxy: + external: true diff --git a/infrastructure/migrations/001_schema.sql b/infrastructure/migrations/001_schema.sql new file mode 100644 index 0000000..079b730 --- /dev/null +++ b/infrastructure/migrations/001_schema.sql @@ -0,0 +1,237 @@ +-- nightly — Vollständiges Datenbankschema mit Row Level Security +-- Wird einmalig nach dem ersten Supabase-Start ausgeführt + +-- ── Enums ────────────────────────────────────────────────────────────────── + +DO $$ BEGIN + CREATE TYPE post_mood AS ENUM ('still', 'unruhig', 'melancholisch', 'aufgedreht'); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + CREATE TYPE review_status AS ENUM ('ok', 'urgent', 'hidden', 'approved', 'removed'); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + CREATE TYPE report_reason AS ENUM ('hate', 'harassment', 'selfharm', 'illegal', 'spam', 'other'); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- ── Tabellen ──────────────────────────────────────────────────────────────── + +-- Erweiterung des Supabase-Auth-Schemas +CREATE TABLE IF NOT EXISTS public.profiles ( + id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + username TEXT UNIQUE NOT NULL CHECK (username ~ '^[a-z0-9_.]{3,20}$'), + display_name TEXT NOT NULL, + bio TEXT, + avatar_url TEXT, + push_token TEXT, + is_pro BOOLEAN DEFAULT FALSE NOT NULL, + is_admin BOOLEAN DEFAULT FALSE NOT NULL, + anon_slots_used INT DEFAULT 0 NOT NULL, + anon_slots_reset_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL +); + +CREATE TABLE IF NOT EXISTS public.posts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + content TEXT NOT NULL CHECK (LENGTH(TRIM(content)) BETWEEN 1 AND 280), + mood post_mood, + is_anonymous BOOLEAN DEFAULT FALSE NOT NULL, + review_status review_status DEFAULT 'ok' NOT NULL, + deleted_at TIMESTAMPTZ, -- soft delete: Post bleibt im Tagebuch + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL +); + +CREATE TABLE IF NOT EXISTS public.resonances ( + post_id UUID NOT NULL REFERENCES public.posts(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + PRIMARY KEY (post_id, user_id) +); + +CREATE TABLE IF NOT EXISTS public.whispers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + from_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + to_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + post_id UUID REFERENCES public.posts(id) ON DELETE SET NULL, + content TEXT NOT NULL CHECK (LENGTH(TRIM(content)) BETWEEN 1 AND 140), + read_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + CHECK (from_user_id != to_user_id) +); + +CREATE TABLE IF NOT EXISTS public.follows ( + follower_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + following_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + PRIMARY KEY (follower_id, following_id), + CHECK (follower_id != following_id) +); + +CREATE TABLE IF NOT EXISTS public.reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + post_id UUID NOT NULL REFERENCES public.posts(id) ON DELETE CASCADE, + reporter_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + reason report_reason NOT NULL, + details TEXT CHECK (LENGTH(details) <= 500), + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + UNIQUE (post_id, reporter_id) +); + +-- ── Indizes ───────────────────────────────────────────────────────────────── + +CREATE INDEX IF NOT EXISTS idx_posts_user_id ON public.posts(user_id); +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) + WHERE review_status != 'ok'; +CREATE INDEX IF NOT EXISTS idx_resonances_post ON public.resonances(post_id); +CREATE INDEX IF NOT EXISTS idx_follows_follower ON public.follows(follower_id); +CREATE INDEX IF NOT EXISTS idx_whispers_to ON public.whispers(to_user_id, read_at); + +-- ── View: Feed Posts ──────────────────────────────────────────────────────── +-- Wird in der iOS-App direkt abgefragt — joins alles zusammen + +CREATE OR REPLACE VIEW public.feed_posts AS +SELECT + p.id, + p.content, + p.mood, + p.is_anonymous, + p.created_at, + -- Autor (für anonyme Posts: null) + 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, + -- Resonance-Zähler + 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'; + +-- ── Trigger: Profil automatisch anlegen ───────────────────────────────────── + +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER AS $$ +BEGIN + INSERT INTO public.profiles (id, username, display_name) + VALUES ( + NEW.id, + COALESCE(NEW.raw_user_meta_data->>'username', 'user_' || SUBSTR(NEW.id::text, 1, 8)), + COALESCE(NEW.raw_user_meta_data->>'display_name', 'User') + ) + ON CONFLICT (id) DO NOTHING; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); + +-- ── Trigger: Auto-Hidden bei >= 5 Reports ─────────────────────────────────── + +CREATE OR REPLACE FUNCTION public.handle_new_report() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE v_count int; +BEGIN + SELECT COUNT(*) INTO v_count FROM public.reports WHERE post_id = NEW.post_id; + IF v_count >= 5 THEN + UPDATE public.posts + SET review_status = 'hidden' + WHERE id = NEW.post_id AND review_status = 'ok'; + END IF; + IF NEW.reason IN ('selfharm', 'illegal') THEN + UPDATE public.posts SET review_status = 'urgent' + WHERE id = NEW.post_id AND review_status = 'ok'; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS on_new_report ON public.reports; +CREATE TRIGGER on_new_report + AFTER INSERT ON public.reports + FOR EACH ROW EXECUTE FUNCTION public.handle_new_report(); + +-- ── Funktion: Username-Login ───────────────────────────────────────────────── +-- Gibt die E-Mail zu einem Username zurück (nur für Auth-Flow) + +CREATE OR REPLACE FUNCTION public.get_email_by_username(p_username text) +RETURNS text LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE v_email text; +BEGIN + SELECT u.email INTO v_email + FROM auth.users u + JOIN public.profiles p ON p.id = u.id + WHERE LOWER(p.username) = LOWER(p_username); + RETURN v_email; +END; +$$; + +-- Funktion: Account vollständig löschen (DSGVO) +CREATE OR REPLACE FUNCTION public.delete_my_account() +RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $$ +BEGIN + DELETE FROM auth.users WHERE id = auth.uid(); +END; +$$; + +-- ── Row Level Security (RLS) ──────────────────────────────────────────────── + +ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.posts ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.resonances ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.whispers ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.follows ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.reports ENABLE ROW LEVEL SECURITY; + +-- profiles: jeder darf lesen, nur eigenes darf geändert werden +CREATE POLICY "profiles_select" ON public.profiles FOR SELECT USING (true); +CREATE POLICY "profiles_update" ON public.profiles FOR UPDATE USING (auth.uid() = id); + +-- posts: Feed-Sichtbarkeit (über View geregelt), eigene Verwaltung +CREATE POLICY "posts_select" ON public.posts FOR SELECT + USING ( + deleted_at IS NULL + AND review_status IN ('ok', 'approved') + AND ( + NOT is_anonymous + OR user_id = auth.uid() + ) + ); +CREATE POLICY "posts_insert" ON public.posts FOR INSERT + WITH CHECK (auth.uid() = user_id); +CREATE POLICY "posts_update_own" ON public.posts FOR UPDATE + USING (auth.uid() = user_id AND deleted_at IS NULL); +-- Soft-Delete: nur eigene Posts dürfen als gelöscht markiert werden +CREATE POLICY "posts_softdelete" ON public.posts FOR UPDATE + USING (auth.uid() = user_id) + WITH CHECK (deleted_at IS NOT NULL); + +-- resonances +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_delete" ON public.resonances FOR DELETE USING (auth.uid() = user_id); + +-- whispers: nur Absender und Empfänger +CREATE POLICY "whispers_select" ON public.whispers FOR SELECT + USING (auth.uid() = from_user_id OR auth.uid() = to_user_id); +CREATE POLICY "whispers_insert" ON public.whispers FOR INSERT + WITH CHECK (auth.uid() = from_user_id); + +-- follows +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_delete" ON public.follows FOR DELETE USING (auth.uid() = follower_id); + +-- reports: nur eigene einsehen +CREATE POLICY "reports_insert" ON public.reports FOR INSERT WITH CHECK (auth.uid() = reporter_id); +CREATE POLICY "reports_select_own" ON public.reports FOR SELECT USING (auth.uid() = reporter_id); diff --git a/infrastructure/scheduler/Dockerfile b/infrastructure/scheduler/Dockerfile new file mode 100644 index 0000000..0353511 --- /dev/null +++ b/infrastructure/scheduler/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY src ./src +# APNs-Zertifikat-Verzeichnis (optional) +RUN mkdir -p /app/certs +CMD ["node", "src/index.js"] diff --git a/infrastructure/scheduler/package.json b/infrastructure/scheduler/package.json new file mode 100644 index 0000000..6d1592a --- /dev/null +++ b/infrastructure/scheduler/package.json @@ -0,0 +1,13 @@ +{ + "name": "nightly-scheduler", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js" + }, + "dependencies": { + "@supabase/supabase-js": "^2.49.0", + "node-apn": "^3.0.0" + } +} diff --git a/infrastructure/scheduler/src/index.js b/infrastructure/scheduler/src/index.js new file mode 100644 index 0000000..59bcb67 --- /dev/null +++ b/infrastructure/scheduler/src/index.js @@ -0,0 +1,216 @@ +/** + * nightly — APNs Scheduler + * + * Sendet täglich einen Push-Ping an alle User zu einem zufälligen + * Zeitpunkt zwischen WINDOW_START_HOUR und WINDOW_END_HOUR. + * + * ⚠️ APNs erfordert einen bezahlten Apple Developer Account ($99/Jahr). + * Ohne APNS_KEY_PATH läuft der Scheduler, sendet aber keine Pushs. + */ + +import { createClient } from '@supabase/supabase-js' +import apn from 'node-apn' + +// ── Supabase Client ──────────────────────────────────────────────────────── + +const supabase = createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_SERVICE_ROLE_KEY // Service Role für Admin-Zugriff +) + +// ── APNs Provider ─────────────────────────────────────────────────────────── + +const APNS_ENABLED = !!process.env.APNS_KEY_PATH + +let apnProvider = null + +if (APNS_ENABLED) { + apnProvider = new apn.Provider({ + token: { + key: process.env.APNS_KEY_PATH, + keyId: process.env.APNS_KEY_ID, + teamId: process.env.APNS_TEAM_ID, + }, + production: process.env.NODE_ENV === 'production' + }) + console.log('[apns] Provider initialisiert') +} else { + console.warn('[apns] Kein APNS_KEY_PATH gesetzt — Push-Benachrichtigungen deaktiviert') + console.warn('[apns] Für APNs wird ein bezahlter Apple Developer Account benötigt ($99/Jahr)') +} + +// ── Konfiguration ─────────────────────────────────────────────────────────── + +const WINDOW_START = parseInt(process.env.WINDOW_START_HOUR ?? '2') +const WINDOW_END = parseInt(process.env.WINDOW_END_HOUR ?? '5') +const BUNDLE_ID = process.env.APNS_BUNDLE_ID ?? 'app.nightly' + +// Nachrichten für den nächtlichen Ping +const PING_MESSAGES = [ + { title: 'nightly', body: 'Das Fenster ist offen. Was geht dir durch den Kopf? 🌙' }, + { title: 'nightly', body: 'Alle anderen sind auch wach. Was beschäftigt dich gerade?' }, + { title: 'nightly', body: 'Kein Filter. Keine Maske. Nur dein Gedanke.' }, + { title: 'nightly', body: 'Was würdest du sagen, wenn niemand zuhört?' }, + { title: 'nightly', body: 'Es ist mitten in der Nacht. Sei ehrlich.' }, +] + +// ── Ping-Zeit-Verwaltung ──────────────────────────────────────────────────── +// Jeden Tag wird eine neue zufällige Zeit festgelegt + +let todaysPingTime = null +let lastPingDate = null + +function getTodaysPingTime() { + const today = new Date().toDateString() + + if (lastPingDate === today && todaysPingTime) { + return todaysPingTime + } + + const hour = WINDOW_START + Math.floor(Math.random() * (WINDOW_END - WINDOW_START)) + const minute = Math.floor(Math.random() * 60) + + todaysPingTime = { hour, minute } + lastPingDate = today + + console.log(`[scheduler] Heutiger Ping: ${String(hour).padStart(2,'0')}:${String(minute).padStart(2,'0')} Uhr`) + return todaysPingTime +} + +// ── Ping senden ───────────────────────────────────────────────────────────── + +async function sendNightlyPing() { + console.log('[scheduler] Sende nightly Ping...') + + const { data: profiles, error } = await supabase + .from('profiles') + .select('id, push_token') + .not('push_token', 'is', null) + + if (error) { + console.error('[scheduler] Fehler beim Abrufen der Tokens:', error) + return + } + + if (!profiles?.length) { + console.log('[scheduler] Keine registrierten Push-Tokens') + return + } + + if (!APNS_ENABLED) { + console.log(`[scheduler] APNs deaktiviert — würde an ${profiles.length} User senden`) + return + } + + const msg = PING_MESSAGES[Math.floor(Math.random() * PING_MESSAGES.length)] + let sent = 0 + let failed = 0 + + for (const { push_token } of profiles) { + const note = new apn.Notification() + note.expiry = Math.floor(Date.now() / 1000) + 3 * 3600 // 3h gültig + note.badge = 1 + note.sound = 'default' + note.alert = msg + note.topic = BUNDLE_ID + note.payload = { type: 'nightly_ping' } + + try { + const result = await apnProvider.send(note, push_token) + if (result.failed?.length > 0) { + failed++ + // Token ungültig → aus DB entfernen + if (result.failed[0]?.response?.reason === 'BadDeviceToken') { + await supabase + .from('profiles') + .update({ push_token: null }) + .eq('push_token', push_token) + } + } else { + sent++ + } + } catch (err) { + failed++ + console.error(`[apns] Fehler für Token:`, err.message) + } + } + + console.log(`[scheduler] Ping gesendet: ${sent} erfolgreich, ${failed} fehlgeschlagen`) +} + +// ── Resonance-Milestone-Benachrichtigungen ────────────────────────────────── +// "X Personen wurden von deinem Gedanken getroffen" + +const MILESTONES = new Set([1, 5, 10, 25, 50, 100]) + +async function checkResonanceMilestones() { + // Holt Posts die in den letzten 2 Minuten eine neue Resonance erreicht haben + const { data: posts } = await supabase + .from('resonances') + .select('post_id, post:posts(user_id, content, is_anonymous)') + .gte('created_at', new Date(Date.now() - 2 * 60 * 1000).toISOString()) + + if (!posts?.length) return + + // Gruppiere nach post_id + const grouped = {} + for (const r of posts) { + if (!grouped[r.post_id]) grouped[r.post_id] = r.post + } + + for (const [postId, post] of Object.entries(grouped)) { + if (!post?.user_id) continue + + const { count } = await supabase + .from('resonances') + .select('*', { count: 'exact', head: true }) + .eq('post_id', postId) + + if (!MILESTONES.has(count)) continue + + const { data: profile } = await supabase + .from('profiles') + .select('push_token') + .eq('id', post.user_id) + .single() + + if (!profile?.push_token || !APNS_ENABLED) continue + + const note = new apn.Notification() + note.badge = 1 + note.sound = 'default' + note.alert = { + title: 'nightly', + body: count === 1 + ? 'Dein Gedanke hat jemanden getroffen 🫀' + : `${count} Menschen wurden von deinem Gedanken getroffen 🫀` + } + note.topic = BUNDLE_ID + note.payload = { type: 'resonance_milestone', postId } + + await apnProvider.send(note, profile.push_token).catch(() => {}) + } +} + +// ── Hauptschleife ──────────────────────────────────────────────────────────── + +console.log('[nightly scheduler] Gestartet') +console.log(`[nightly scheduler] Fenster: ${WINDOW_START}:00 – ${WINDOW_END}:00 Uhr`) + +setInterval(async () => { + const now = new Date() + const { hour, minute } = getTodaysPingTime() + + // Ping zur festgelegten Zeit senden + if (now.getHours() === hour && now.getMinutes() === minute) { + await sendNightlyPing() + } + + // Resonance-Milestones alle 2 Minuten prüfen + if (now.getMinutes() % 2 === 0) { + await checkResonanceMilestones() + } +}, 60_000) + +// Sofortige Prüfung beim Start (ohne zu pingen) +getTodaysPingTime() diff --git a/infrastructure/setup.sh b/infrastructure/setup.sh new file mode 100644 index 0000000..9c6ba36 --- /dev/null +++ b/infrastructure/setup.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# nightly — Server-Setup-Skript +# Führe das auf deinem Server aus: bash setup.sh +set -euo pipefail + +DOMAIN="${1:-xxx.dk0.dev}" # Ersetze xxx mit deinem App-Namen +APP_DIR="/opt/nightly" + +echo "▶ nightly setup für $DOMAIN" + +# --- Abhängigkeiten --- +if ! command -v docker &>/dev/null; then + echo "▶ Docker installieren..." + curl -fsSL https://get.docker.com | sh + sudo usermod -aG docker "$USER" +fi + +# --- Verzeichnis anlegen --- +sudo mkdir -p "$APP_DIR" +sudo chown "$USER:$USER" "$APP_DIR" +cd "$APP_DIR" + +# --- Offizielles Supabase-Docker-Setup holen --- +if [ ! -d supabase ]; then + echo "▶ Supabase Docker-Konfiguration laden..." + git clone --depth 1 https://github.com/supabase/supabase.git supabase-repo + cp -r supabase-repo/docker supabase + rm -rf supabase-repo +fi + +# --- Unsere Konfiguration kopieren --- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cp "$SCRIPT_DIR/docker-compose.override.yml" "$APP_DIR/supabase/" +cp "$SCRIPT_DIR/Caddyfile" "$APP_DIR/supabase/" +cp -r "$SCRIPT_DIR/scheduler" "$APP_DIR/supabase/" + +# --- .env anlegen wenn nicht vorhanden --- +if [ ! -f "$APP_DIR/supabase/.env" ]; then + cp "$APP_DIR/supabase/.env.example" "$APP_DIR/supabase/.env" + cp "$SCRIPT_DIR/.env.nightly" "$APP_DIR/supabase/.env.nightly" + echo "" + echo "⚠️ WICHTIG: Editiere $APP_DIR/supabase/.env und .env.nightly" + echo " Setze mindestens:" + echo " - POSTGRES_PASSWORD" + echo " - JWT_SECRET (min. 32 Zeichen)" + echo " - SITE_URL=https://api.$DOMAIN" + echo "" + echo " Dann: cd $APP_DIR/supabase && docker compose -f docker-compose.yml -f docker-compose.override.yml up -d" + exit 0 +fi + +# --- Migrations einspielen --- +echo "▶ Datenbank-Schema einspielen..." +# Warte auf Postgres +until docker compose exec -T db pg_isready -U postgres; do sleep 2; done +docker compose exec -T db psql -U postgres -d postgres \ + < "$SCRIPT_DIR/migrations/001_schema.sql" + +echo "" +echo "✅ Setup abgeschlossen!" +echo " Admin: https://studio.$DOMAIN" +echo " API: https://api.$DOMAIN" +echo " Logs: docker compose logs -f" diff --git a/ios/NightThoughts/AppState.swift b/ios/NightThoughts/AppState.swift new file mode 100644 index 0000000..e046c4c --- /dev/null +++ b/ios/NightThoughts/AppState.swift @@ -0,0 +1,124 @@ +import SwiftUI +import Supabase + +@MainActor +class AppState: ObservableObject { + @Published var isAuthenticated = false + @Published var currentUser: User? + @Published var windowState: WindowState = .closed + + private var windowTimer: Timer? + + enum WindowState { case closed, open, posted, missed } + + init() { + Task { await checkSession() } + startWindowTimer() + observeAuthChanges() + } + + // MARK: - Auth + + func checkSession() async { + do { + let session = try await supabase.auth.session + isAuthenticated = true + await loadProfile(userId: session.user.id) + } catch { + isAuthenticated = false + } + } + + func signIn(email: String, password: String) async throws { + try await supabase.signIn(email: email, password: password) + let session = try await supabase.auth.session + isAuthenticated = true + await loadProfile(userId: session.user.id) + } + + func signIn(username: String, password: String) async throws { + try await supabase.signIn(username: username, password: password) + let session = try await supabase.auth.session + isAuthenticated = true + await loadProfile(userId: session.user.id) + } + + func signUp(email: String, password: String, username: String, displayName: String) async throws { + try await supabase.signUp(email: email, password: password, username: username, displayName: displayName) + let session = try await supabase.auth.session + isAuthenticated = true + await loadProfile(userId: session.user.id) + } + + func signOut() { + Task { + try? await supabase.auth.signOut() + } + isAuthenticated = false + currentUser = nil + } + + func deleteAccount() async throws { + try await supabase.deleteAccount() + isAuthenticated = false + currentUser = nil + } + + private func loadProfile(userId: UUID) async { + guard let profile = try? await supabase.getMyProfile() else { return } + currentUser = User( + id: profile.id.uuidString, + username: profile.username, + displayName: profile.displayName, + bio: profile.bio, + avatarURL: profile.avatarUrl.flatMap(URL.init), + followerCount: 0, + followingCount: 0, + postCount: 0, + isFollowing: false + ) + } + + private func observeAuthChanges() { + Task { + for await (event, session) in await supabase.auth.authStateChanges { + switch event { + case .signedIn: + if let session { + isAuthenticated = true + await loadProfile(userId: session.user.id) + } + case .signedOut, .userDeleted: + isAuthenticated = false + currentUser = nil + default: + break + } + } + } + } + + // MARK: - Window State + + func updateWindowState() { + let hour = Calendar.current.component(.hour, from: Date()) + guard hour >= 2 && hour < 5 else { windowState = .closed; return } + + let hasPosted = UserDefaults.standard.object(forKey: "lastPostDate") + .flatMap { $0 as? Date } + .map { Calendar.current.isDateInToday($0) } ?? false + windowState = hasPosted ? .posted : .open + } + + func markAsPosted() { + UserDefaults.standard.set(Date(), forKey: "lastPostDate") + updateWindowState() + } + + private func startWindowTimer() { + updateWindowState() + windowTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in self?.updateWindowState() } + } + } +} diff --git a/ios/NightThoughts/Extensions/Colors.swift b/ios/NightThoughts/Extensions/Colors.swift new file mode 100644 index 0000000..c16e459 --- /dev/null +++ b/ios/NightThoughts/Extensions/Colors.swift @@ -0,0 +1,79 @@ +import SwiftUI + +// MARK: - Design Tokens + +extension Color { + // Backgrounds — kein reines Schwarz, sondern Mitternachtsblau + static let nightBase = Color(hex: "080810") // Haupt-Hintergrund + static let nightSurface = Color(hex: "0E0E1C") // Karten, Sheets + static let nightRaised = Color(hex: "151528") // Elevated surfaces + static let nightBorder = Color(white: 1, opacity: 0.06) + + // Text + static let nightPrimary = Color(hex: "EEEEF8") + static let nightSecondary = Color(hex: "64647A") + static let nightTertiary = Color(hex: "3A3A52") + + // Akzente + static let nightPurple = Color(hex: "7B4FE8") + static let nightPurpleSoft = Color(hex: "9B77F0") + static let nightGreen = Color(hex: "34D399") + static let nightRed = Color(hex: "F27474") + + // Hex initializer + init(hex: String) { + let h = hex.trimmingCharacters(in: .alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: h).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch h.count { + case 6: (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default:(a, r, g, b) = (255, 255, 255, 255) + } + self.init(.sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255) + } +} + +// MARK: - Mood (passt hier semantisch besser rein als in Post.swift) + +extension Mood { + var color: Color { + switch self { + case .still: return Color(hex: "4A9EFF") + case .unruhig: return Color(hex: "FF8C42") + case .melancholisch: return Color(hex: "A855F7") + case .aufgedreht: return Color(hex: "10D08A") + } + } + var label: String { rawValue } + var emoji: String { + switch self { + case .still: return "◌" + case .unruhig: return "◎" + case .melancholisch: return "◑" + case .aufgedreht: return "◉" + } + } +} + +// MARK: - Typography helpers + +extension Font { + static func nightTitle(_ size: CGFloat) -> Font { + .system(size: size, weight: .bold, design: .rounded) + } + static func nightBody(_ size: CGFloat) -> Font { + .system(size: size, weight: .regular) + } + static func nightMono(_ size: CGFloat) -> Font { + .system(size: size, design: .monospaced) + } + static func nightLabel(_ size: CGFloat, weight: Font.Weight = .medium) -> Font { + .system(size: size, weight: weight) + } +} diff --git a/ios/NightThoughts/Models/Post.swift b/ios/NightThoughts/Models/Post.swift new file mode 100644 index 0000000..8c6012f --- /dev/null +++ b/ios/NightThoughts/Models/Post.swift @@ -0,0 +1,107 @@ +import Foundation + +// MARK: - Mood + +enum Mood: String, Codable, CaseIterable { + case still = "still" + case unruhig = "unruhig" + case melancholisch = "melancholisch" + case aufgedreht = "aufgedreht" + // color, label, emoji → Colors.swift extension +} + +// MARK: - Post + +struct Post: Identifiable, Codable { + let id: String + let author: User + let content: String + let mood: Mood? + let createdAt: Date + var resonanceCount: Int // "Hat mich getroffen" + var hasResonated: Bool // Current user's reaction + var commentCount: Int + let isAnonymous: Bool + let nightOf: Date + + var isExpired: Bool { + Date().timeIntervalSince(createdAt) > 14 * 3_600 + } + + // Is this post in the "Gerade Jetzt" window (< 10 min old) + var isRightNow: Bool { + Date().timeIntervalSince(createdAt) < 10 * 60 + } + + var formattedTime: String { + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f.string(from: createdAt) + } + + var timeAgo: String { + let diff = Date().timeIntervalSince(createdAt) + if diff < 60 { return "gerade eben" } + if diff < 3_600 { return "\(Int(diff / 60))m" } + return "\(Int(diff / 3_600))h" + } + + static let previews: [Post] = [ + Post( + id: "1", + author: .preview, + content: "warum denk ich um 3 uhr morgens noch an das was ich 2019 gesagt hab", + mood: .melancholisch, + createdAt: Date().addingTimeInterval(-180), + resonanceCount: 12, + hasResonated: false, + commentCount: 3, + isAnonymous: false, + nightOf: Date() + ), + Post( + id: "2", + author: User( + id: "2", username: "insomniac_", displayName: "can't sleep", + bio: nil, avatarURL: nil, + followerCount: 88, followingCount: 44, postCount: 12, isFollowing: true + ), + content: "das licht vom handy macht alles schlimmer aber ich leg es trotzdem nicht weg", + mood: .unruhig, + createdAt: Date().addingTimeInterval(-900), + resonanceCount: 8, + hasResonated: true, + commentCount: 1, + isAnonymous: false, + nightOf: Date() + ), + Post( + id: "3", + author: .preview, + content: "ich warte irgendwie immer noch auf eine nachricht von dir obwohl ich weiß dass sie nicht kommt", + mood: .melancholisch, + createdAt: Date().addingTimeInterval(-300), + resonanceCount: 31, + hasResonated: true, + commentCount: 7, + isAnonymous: true, + nightOf: Date() + ), + Post( + id: "4", + author: User( + id: "4", username: "felix.nacht", displayName: "Felix", + bio: nil, avatarURL: nil, + followerCount: 33, followingCount: 20, postCount: 8, isFollowing: false + ), + content: "hab gerade realisiert dass ich seit 4 stunden auf tiktok bin und morgen um 7 aufstehen muss", + mood: .aufgedreht, + createdAt: Date().addingTimeInterval(-60), + resonanceCount: 5, + hasResonated: false, + commentCount: 0, + isAnonymous: false, + nightOf: Date() + ) + ] +} diff --git a/ios/NightThoughts/Models/User.swift b/ios/NightThoughts/Models/User.swift new file mode 100644 index 0000000..7ada9b0 --- /dev/null +++ b/ios/NightThoughts/Models/User.swift @@ -0,0 +1,25 @@ +import Foundation + +struct User: Identifiable, Codable, Equatable { + let id: String + let username: String + var displayName: String + var bio: String? + var avatarURL: URL? + var followerCount: Int + var followingCount: Int + var postCount: Int + var isFollowing: Bool + + static let preview = User( + id: "preview", + username: "nightowl", + displayName: "Night Owl", + bio: "3 Uhr ist meine goldene Stunde", + avatarURL: nil, + followerCount: 142, + followingCount: 89, + postCount: 37, + isFollowing: false + ) +} diff --git a/ios/NightThoughts/NightlyApp.swift b/ios/NightThoughts/NightlyApp.swift new file mode 100644 index 0000000..10b21a4 --- /dev/null +++ b/ios/NightThoughts/NightlyApp.swift @@ -0,0 +1,67 @@ +import SwiftUI + +@main +struct NightlyApp: App { + @StateObject private var appState = AppState() + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + WindowGroup { + RootView() + .environmentObject(appState) + .preferredColorScheme(.dark) + } + } +} + +// MARK: - App Delegate + +class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + UNUserNotificationCenter.current().delegate = self + return true + } + + // ⚠️ Push Notifications: erfordert bezahlten Apple Developer Account ($99/Jahr) + // Ohne Developer-Account kann dieser Code nicht getestet werden (nur Simulator ohne Pushs) + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() + Task { try? await supabase.savePushToken(token) } + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + print("APNs Registrierung fehlgeschlagen:", error.localizedDescription) + // Häufige Ursache: kein bezahlter Developer Account + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound, .badge]) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + NotificationCenter.default.post(name: .nightlyPingReceived, object: nil) + completionHandler() + } +} + +extension Notification.Name { + static let nightlyPingReceived = Notification.Name("nightlyPingReceived") +} diff --git a/ios/NightThoughts/Secrets.swift b/ios/NightThoughts/Secrets.swift new file mode 100644 index 0000000..a4b667b --- /dev/null +++ b/ios/NightThoughts/Secrets.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Konfiguration aus dem Xcode Build-System (xcconfig / Info.plist). +/// +/// Setup: +/// 1. Datei `Config.xcconfig` im Projektverzeichnis anlegen (nicht committen!): +/// SUPABASE_URL = https://api.xxx.dk0.dev +/// SUPABASE_ANON_KEY = eyJhbGci... +/// +/// 2. In Xcode: Project → Info → Configurations → Debug & Release auf Config.xcconfig setzen +/// 3. In Info.plist eintragen: +/// SUPABASE_URL → $(SUPABASE_URL) +/// SUPABASE_ANON_KEY → $(SUPABASE_ANON_KEY) + +enum Config { + static let supabaseURL: URL = { + guard + let raw = Bundle.main.object(forInfoDictionaryKey: "SUPABASE_URL") as? String, + !raw.isEmpty, + let url = URL(string: raw) + else { + // Fallback für Entwicklung — ersetze mit deiner URL + return URL(string: "https://api.xxx.dk0.dev")! + } + return url + }() + + static let supabaseAnonKey: String = { + let key = Bundle.main.object(forInfoDictionaryKey: "SUPABASE_ANON_KEY") as? String ?? "" + if key.isEmpty { + print("⚠️ SUPABASE_ANON_KEY nicht gesetzt — Config.xcconfig prüfen") + } + return key + }() +} diff --git a/ios/NightThoughts/Services/APIService.swift b/ios/NightThoughts/Services/APIService.swift new file mode 100644 index 0000000..52dad3b --- /dev/null +++ b/ios/NightThoughts/Services/APIService.swift @@ -0,0 +1,127 @@ +import Foundation +import UIKit + +actor APIService { + static let shared = APIService() + + // Change to your server URL + private let baseURL = URL(string: "https://api.nightly.app/")! + + private var authToken: String? { KeychainService.shared.getToken() } + + // MARK: - Auth + + func login(username: String, password: String) async throws { + let r: AuthResponse = try await post("auth/login", body: [ + "username": username, "password": password + ]) + KeychainService.shared.saveToken(r.token) + } + + func register(username: String, password: String, displayName: String) async throws { + let r: AuthResponse = try await post("auth/register", body: [ + "username": username, "password": password, "displayName": displayName + ]) + KeychainService.shared.saveToken(r.token) + } + + func getCurrentUser() async throws -> User { + try await get("users/me") + } + + // MARK: - Posts + + func getFeed() async throws -> [Post] { + try await get("posts/feed") + } + + func createPost(content: String, mood: Mood, isAnonymous: Bool) async throws { + let _: EmptyResponse = try await post("posts", body: [ + "content": content, + "mood": mood.rawValue, + "isAnonymous": isAnonymous + ]) + } + + func resonate(postId: String) async throws { + let _: EmptyResponse = try await post("posts/\(postId)/resonate", body: [:]) + } + + func unresoante(postId: String) async throws { + let _: EmptyResponse = try await delete("posts/\(postId)/resonate") + } + + func sendWhisper(toUserId: String, content: String) async throws { + let _: EmptyResponse = try await post("users/\(toUserId)/whisper", body: ["content": content]) + } + + // MARK: - Users + + func getUserPosts(userId: String) async throws -> [Post] { + try await get("users/\(userId)/posts") + } + + func getUserStreak(userId: String) async throws -> Int { + let r: StreakResponse = try await get("users/\(userId)/streak") + return r.streak + } + + func registerPushToken(_ token: String) async { + _ = try? await post("users/me/push-token", body: ["token": token]) as EmptyResponse + } + + // MARK: - HTTP + + private func get(_ path: String) async throws -> T { + try await perform(makeRequest("GET", path: path)) + } + + @discardableResult + private func post(_ path: String, body: [String: Any]) async throws -> T { + var req = makeRequest("POST", path: path) + req.httpBody = try JSONSerialization.data(withJSONObject: body) + return try await perform(req) + } + + @discardableResult + private func delete(_ path: String) async throws -> T { + try await perform(makeRequest("DELETE", path: path)) + } + + private func makeRequest(_ method: String, path: String) -> URLRequest { + var req = URLRequest(url: baseURL.appendingPathComponent(path)) + req.httpMethod = method + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let t = authToken { req.setValue("Bearer \(t)", forHTTPHeaderField: "Authorization") } + return req + } + + private func perform(_ request: URLRequest) async throws -> T { + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + guard (200...299).contains(http.statusCode) else { + let msg = (try? JSONDecoder().decode(APIErrorBody.self, from: data))?.message + ?? HTTPURLResponse.localizedString(forStatusCode: http.statusCode) + throw APIError.serverError(msg) + } + let dec = JSONDecoder() + dec.dateDecodingStrategy = .iso8601 + return try dec.decode(T.self, from: data) + } +} + +private struct AuthResponse: Decodable { let token: String } +private struct StreakResponse: Decodable { let streak: Int } +private struct APIErrorBody: Decodable { let message: String } +struct EmptyResponse: Decodable {} + +enum APIError: LocalizedError { + case invalidResponse + case serverError(String) + var errorDescription: String? { + switch self { + case .invalidResponse: return "Ungültige Serverantwort" + case .serverError(let m): return m + } + } +} diff --git a/ios/NightThoughts/Services/KeychainService.swift b/ios/NightThoughts/Services/KeychainService.swift new file mode 100644 index 0000000..e6d17cc --- /dev/null +++ b/ios/NightThoughts/Services/KeychainService.swift @@ -0,0 +1,43 @@ +import Foundation +import Security + +final class KeychainService { + static let shared = KeychainService() + private let service = "app.nightly" + private let account = "authToken" + + func saveToken(_ token: String) { + let data = Data(token.utf8) + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecValueData: data + ] + SecItemDelete(query as CFDictionary) + SecItemAdd(query as CFDictionary, nil) + } + + func getToken() -> String? { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + func deleteToken() { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/ios/NightThoughts/Services/NotificationService.swift b/ios/NightThoughts/Services/NotificationService.swift new file mode 100644 index 0000000..095ddce --- /dev/null +++ b/ios/NightThoughts/Services/NotificationService.swift @@ -0,0 +1,16 @@ +import UserNotifications +import UIKit + +final class NotificationService { + static let shared = NotificationService() + + func requestPermission() async -> Bool { + (try? await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound, .badge])) ?? false + } + + @MainActor + func registerForRemoteNotifications() { + UIApplication.shared.registerForRemoteNotifications() + } +} diff --git a/ios/NightThoughts/Services/RealtimeService.swift b/ios/NightThoughts/Services/RealtimeService.swift new file mode 100644 index 0000000..bda51a6 --- /dev/null +++ b/ios/NightThoughts/Services/RealtimeService.swift @@ -0,0 +1,86 @@ +import Foundation +import Supabase + +/// Verwaltet die Echtzeit-Verbindung für "Gerade Jetzt". +/// Neue Posts erscheinen sofort ohne Polling. +@MainActor +class RealtimeService: ObservableObject { + @Published var newPostsCount = 0 + + private var channel: RealtimeChannelV2? + private var onNewPost: ((Post) -> Void)? + + func startListening(onNewPost: @escaping (Post) -> Void) async { + self.onNewPost = onNewPost + guard channel == nil else { return } + + let ch = await supabase.channel("public:posts") + + // Neue Posts in Echtzeit empfangen + let stream = await ch.postgresChange( + InsertAction.self, + schema: "public", + table: "posts" + ) + + await ch.subscribe() + self.channel = ch + + // Stream im Hintergrund konsumieren + Task { [weak self] in + for await action in stream { + await self?.handleInsert(action) + } + } + } + + func stopListening() async { + if let ch = channel { + await supabase.removeChannel(ch) + channel = nil + } + } + + private func handleInsert(_ action: InsertAction) { + // Den neuen Post aus dem Record dekodieren + guard + let id = action.record["id"]?.stringValue, + let content = action.record["content"]?.stringValue, + let createdAt = action.record["created_at"]?.stringValue + .flatMap({ ISO8601DateFormatter().date(from: $0) }), + let userId = action.record["user_id"]?.stringValue, + let isAnon = action.record["is_anonymous"]?.boolValue + else { return } + + let moodString = action.record["mood"]?.stringValue + let mood = moodString.flatMap(Mood.init(rawValue:)) + + let post = Post( + id: id, + author: User.anonymousPlaceholder, // Profil wird lazily nachgeladen + content: content, + mood: mood, + createdAt: createdAt, + resonanceCount: 0, + hasResonated: false, + commentCount: 0, + isAnonymous: isAnon, + nightOf: createdAt + ) + + newPostsCount += 1 + onNewPost?(post) + } +} + +// MARK: - User placeholder für Realtime (Profil wird nachgeladen) + +extension User { + static let anonymousPlaceholder = User( + id: "anonymous", + username: "anonym", + displayName: "anonym", + bio: nil, avatarURL: nil, + followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false + ) +} diff --git a/ios/NightThoughts/Services/SupabaseService.swift b/ios/NightThoughts/Services/SupabaseService.swift new file mode 100644 index 0000000..4c4e9f3 --- /dev/null +++ b/ios/NightThoughts/Services/SupabaseService.swift @@ -0,0 +1,379 @@ +import Foundation +import Supabase + +// MARK: - Supabase Client (Singleton) + +let supabase = SupabaseClient( + supabaseURL: Config.supabaseURL, + supabaseKey: Config.supabaseAnonKey, + options: SupabaseClientOptions( + db: .init( + encoder: { + let e = JSONEncoder() + e.keyEncodingStrategy = .convertToSnakeCase + e.dateEncodingStrategy = .iso8601 + return e + }(), + decoder: { + let d = JSONDecoder() + d.keyDecodingStrategy = .convertFromSnakeCase + d.dateDecodingStrategy = .iso8601 + return d + }() + ) + ) +) + +// MARK: - Auth + +extension SupabaseClient { + + func signUp(email: String, password: String, username: String, displayName: String) async throws { + try await self.auth.signUp( + email: email, + password: password, + data: [ + "username": .string(username.lowercased()), + "display_name": .string(displayName) + ] + ) + } + + func signIn(email: String, password: String) async throws { + try await self.auth.signIn(email: email, password: password) + } + + /// Login mit Username: holt zuerst die E-Mail, dann normaler Sign-In + func signIn(username: String, password: String) async throws { + let email: String? = try await self + .rpc("get_email_by_username", params: ["p_username": username]) + .execute() + .value + guard let email else { throw AuthError.usernameNotFound } + try await self.auth.signIn(email: email, password: password) + } + + func signOut() async throws { + try await self.auth.signOut() + } + + /// Account vollständig löschen (DSGVO — löscht alles über DB-Funktion) + func deleteAccount() async throws { + try await self.rpc("delete_my_account").execute() + } + + var currentUserId: UUID? { + try? self.auth.session.user.id + } +} + +// MARK: - Profil + +extension SupabaseClient { + + func getMyProfile() async throws -> Profile { + guard let uid = currentUserId else { throw AuthError.notAuthenticated } + return try await self + .from("profiles") + .select() + .eq("id", value: uid) + .single() + .execute() + .value + } + + func getProfile(userId: UUID) async throws -> Profile { + try await self + .from("profiles") + .select() + .eq("id", value: userId) + .single() + .execute() + .value + } + + func updateProfile(displayName: String? = nil, bio: String? = nil) async throws { + guard let uid = currentUserId else { throw AuthError.notAuthenticated } + var update: [String: String] = [:] + if let n = displayName { update["display_name"] = n } + if let b = bio { update["bio"] = b } + guard !update.isEmpty else { return } + try await self.from("profiles").update(update).eq("id", value: uid).execute() + } + + func savePushToken(_ token: String) async throws { + guard let uid = currentUserId else { return } + try await self.from("profiles") + .update(["push_token": token]) + .eq("id", value: uid) + .execute() + } + + func removePushToken() async throws { + guard let uid = currentUserId else { return } + try await self.from("profiles") + .update(["push_token": nil as String?]) + .eq("id", value: uid) + .execute() + } +} + +// MARK: - Posts + +extension SupabaseClient { + + /// Feed: Posts der letzten 14h von gefollowten Usern + eigene + func getFeed() async throws -> [Post] { + guard let uid = currentUserId else { return [] } + + // Erst die gefolgten User-IDs holen + // Supabase gibt Objekte zurück [{following_id:"uuid"}], kein [String] + struct FollowRow: Decodable { let followingId: String } + let followRows: [FollowRow] = try await self + .from("follows") + .select("following_id") + .eq("follower_id", value: uid) + .execute() + .value + + let allIds = followRows.map(\.followingId) + [uid.uuidString] + + let rows: [FeedPostRow] = try await self + .from("feed_posts") + .select() + .in("author_id", values: allIds) + .order("created_at", ascending: false) + .limit(150) + .execute() + .value + + // Eigene Resonances holen (RLS filtert, SDK gibt nur eigene zurück) + let myResonances: [ResonanceRow] = (try? await self + .from("resonances") + .select("post_id") + .eq("user_id", value: uid) + .execute() + .value) ?? [] + + let mySet = Set(myResonances.map(\.postId)) + + return rows.map { row in row.toPost(hasResonated: mySet.contains(row.id)) } + } + + /// Persönliches Tagebuch: alle eigenen Posts, auch gelöschte (soft) + func getDiary() async throws -> [Post] { + guard let uid = currentUserId else { return [] } + let rows: [FeedPostRow] = try await self + .from("posts") + .select("id, content, mood, is_anonymous, created_at, resonance_count:resonances(count)") + .eq("user_id", value: uid) + .is("deleted_at", value: nil) + .order("created_at", ascending: false) + .limit(365) + .execute() + .value + return rows.map { $0.toPost(hasResonated: false) } + } + + func getUserPosts(userId: UUID) async throws -> [Post] { + let rows: [FeedPostRow] = try await self + .from("feed_posts") + .select() + .eq("author_id", value: userId) + .order("created_at", ascending: false) + .limit(50) + .execute() + .value + return rows.map { $0.toPost(hasResonated: false) } + } + + func createPost(content: String, mood: Mood, isAnonymous: Bool) async throws { + guard let uid = currentUserId else { throw AuthError.notAuthenticated } + try await self.from("posts").insert([ + "user_id": uid.uuidString, + "content": content, + "mood": mood.rawValue, + "is_anonymous": isAnonymous + ]).execute() + } + + func softDeletePost(id: String) async throws { + try await self.from("posts") + .update(["deleted_at": ISO8601DateFormatter().string(from: Date())]) + .eq("id", value: id) + .execute() + } +} + +// MARK: - Resonances + +extension SupabaseClient { + + func toggleResonance(postId: String, currentlyActive: Bool) async throws { + guard let uid = currentUserId else { throw AuthError.notAuthenticated } + if currentlyActive { + try await self.from("resonances") + .delete() + .eq("post_id", value: postId) + .eq("user_id", value: uid) + .execute() + } else { + try await self.from("resonances") + .insert(["post_id": postId, "user_id": uid.uuidString]) + .execute() + } + } +} + +// MARK: - Follows + +extension SupabaseClient { + + func follow(userId: UUID) async throws { + guard let uid = currentUserId else { throw AuthError.notAuthenticated } + try await self.from("follows") + .insert(["follower_id": uid.uuidString, "following_id": userId.uuidString]) + .execute() + } + + func unfollow(userId: UUID) async throws { + guard let uid = currentUserId else { throw AuthError.notAuthenticated } + try await self.from("follows") + .delete() + .eq("follower_id", value: uid) + .eq("following_id", value: userId) + .execute() + } + + func getStreak(userId: UUID) async throws -> Int { + // Nächte mit Posts — berechnet in SQL + let rows: [[String: Int]] = (try? await self + .rpc("get_streak", params: ["p_user_id": userId.uuidString]) + .execute() + .value) ?? [] + return rows.first?["streak"] ?? 0 + } +} + +// MARK: - Reports + +extension SupabaseClient { + + func reportPost(postId: String, reason: String, details: String?) async throws { + guard let uid = currentUserId else { throw AuthError.notAuthenticated } + try await self.from("reports").insert([ + "post_id": postId, + "reporter_id": uid.uuidString, + "reason": reason, + "details": details as Any + ]).execute() + } +} + +// MARK: - Whispers + +extension SupabaseClient { + + func sendWhisper(toUserId: UUID, content: String, postId: String?) async throws { + guard let uid = currentUserId else { throw AuthError.notAuthenticated } + try await self.from("whispers").insert([ + "from_user_id": uid.uuidString, + "to_user_id": toUserId.uuidString, + "content": content, + "post_id": postId as Any + ]).execute() + } + + func getMyWhispers() async throws -> [Whisper] { + guard let uid = currentUserId else { return [] } + return try await self + .from("whispers") + .select("*, from_profile:profiles!from_user_id(username, display_name, avatar_url)") + .eq("to_user_id", value: uid) + .order("created_at", ascending: false) + .limit(50) + .execute() + .value + } + + func markWhisperRead(id: UUID) async throws { + try await self.from("whispers") + .update(["read_at": ISO8601DateFormatter().string(from: Date())]) + .eq("id", value: id) + .execute() + } +} + +// MARK: - Errors + +enum AuthError: LocalizedError { + case notAuthenticated + case usernameNotFound + + var errorDescription: String? { + switch self { + case .notAuthenticated: return "Nicht angemeldet" + case .usernameNotFound: return "Benutzername nicht gefunden" + } + } +} + +// MARK: - Row types (Supabase responses) + +struct FeedPostRow: Decodable { + let id: String + let content: String + let mood: String? + let isAnonymous: Bool + let createdAt: Date + let resonanceCount: Int + // Autor (nil bei anonymen Posts die nicht von mir sind) + let authorId: String? + let authorUsername: String? + let authorDisplayName: String? + let authorAvatarUrl: String? + + func toPost(hasResonated: Bool) -> Post { + let author: User? = authorId.map { + User( + id: $0, + username: authorUsername ?? "?", + displayName: authorDisplayName ?? "?", + bio: nil, avatarURL: authorAvatarUrl.flatMap(URL.init), + followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false + ) + } + return Post( + id: id, + author: author ?? User.anonymousPlaceholder, + content: content, + mood: mood.flatMap(Mood.init(rawValue:)), + createdAt: createdAt, + resonanceCount: resonanceCount, + hasResonated: hasResonated, + commentCount: 0, + isAnonymous: isAnonymous, + nightOf: createdAt + ) + } +} + +struct ResonanceRow: Decodable { let postId: String } +struct Profile: Decodable { + let id: UUID + let username: String + let displayName: String + let bio: String? + let avatarUrl: String? + let isPro: Bool + let isAdmin: Bool + let createdAt: Date +} + +struct Whisper: Identifiable, Decodable { + let id: UUID + let fromUserId: UUID + let content: String + let readAt: Date? + let createdAt: Date +} diff --git a/ios/NightThoughts/ViewModels/FeedViewModel.swift b/ios/NightThoughts/ViewModels/FeedViewModel.swift new file mode 100644 index 0000000..ac14288 --- /dev/null +++ b/ios/NightThoughts/ViewModels/FeedViewModel.swift @@ -0,0 +1,40 @@ +import Foundation + +@MainActor +class FeedViewModel: ObservableObject { + @Published var posts: [Post] = [] + @Published var isLoading = false + + func load() async { + isLoading = true + defer { isLoading = false } + do { + posts = try await supabase.getFeed() + } catch { + #if DEBUG + posts = Post.previews + #endif + } + } + + func resonate(_ post: Post) async { + guard let idx = posts.firstIndex(where: { $0.id == post.id }) else { return } + let wasActive = posts[idx].hasResonated + + posts[idx].hasResonated = !wasActive + posts[idx].resonanceCount += wasActive ? -1 : 1 + + do { + try await supabase.toggleResonance(postId: post.id, currentlyActive: wasActive) + } catch { + posts[idx].hasResonated = wasActive + posts[idx].resonanceCount += wasActive ? 1 : -1 + } + } + + /// Neuen Post vom Realtime-Service in den Feed einfügen + func prepend(_ post: Post) { + guard !posts.contains(where: { $0.id == post.id }) else { return } + posts.insert(post, at: 0) + } +} diff --git a/ios/NightThoughts/ViewModels/ProfileViewModel.swift b/ios/NightThoughts/ViewModels/ProfileViewModel.swift new file mode 100644 index 0000000..c94343e --- /dev/null +++ b/ios/NightThoughts/ViewModels/ProfileViewModel.swift @@ -0,0 +1,33 @@ +import Foundation + +@MainActor +class ProfileViewModel: ObservableObject { + @Published var posts: [Post] = [] + @Published var streak: Int = 0 + @Published var isLoading = false + + let userId: UUID + + init(userId: UUID) { + self.userId = userId + } + + convenience init(userIdString: String) { + self.init(userId: UUID(uuidString: userIdString) ?? UUID()) + } + + func load() async { + isLoading = true + defer { isLoading = false } + do { + async let postsTask = supabase.getUserPosts(userId: userId) + async let streakTask = supabase.getStreak(userId: userId) + (posts, streak) = try await (postsTask, streakTask) + } catch { + #if DEBUG + posts = Post.previews + streak = 4 + #endif + } + } +} diff --git a/ios/NightThoughts/Views/ComposeView.swift b/ios/NightThoughts/Views/ComposeView.swift new file mode 100644 index 0000000..d9752c8 --- /dev/null +++ b/ios/NightThoughts/Views/ComposeView.swift @@ -0,0 +1,320 @@ +import SwiftUI + +struct ComposeView: View { + @EnvironmentObject var appState: AppState + @Environment(\.dismiss) var dismiss + + @State private var text = "" + @State private var selectedMood: Mood? = nil + @State private var isAnonymous = false + @State private var isPosting = false + @State private var errorMessage: String? + + private let maxChars = 280 + var remaining: Int { maxChars - text.count } + var canPost: Bool { !text.trimmingCharacters(in: .whitespaces).isEmpty && selectedMood != nil } + + // Background tint based on mood + var moodBackground: Color { + selectedMood?.color.opacity(0.06) ?? .clear + } + + var body: some View { + NavigationStack { + ZStack { + Color.nightBase.ignoresSafeArea() + moodBackground.ignoresSafeArea() + .animation(.easeInOut(duration: 0.5), value: selectedMood) + + VStack(spacing: 0) { + // Top meta bar + HStack { + Label(currentTime, systemImage: "moon.stars.fill") + .font(.nightMono(12)) + .foregroundColor(.nightPurple.opacity(0.7)) + .labelStyle(.titleAndIcon) + + Spacer() + + // Character count + Group { + if remaining <= 30 { + Text("\(remaining)") + .foregroundColor(remaining <= 10 ? .nightRed : .nightSecondary) + } + } + .font(.nightMono(13)) + .animation(.easeInOut, value: remaining) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + + Divider().background(Color.nightBorder) + + // Text field area + ScrollView { + HStack(alignment: .top, spacing: 12) { + // Left: Avatar + VStack(spacing: 0) { + if isAnonymous { + AnonymousAvatar(size: 38) + } else if let user = appState.currentUser { + AvatarView(user: user, size: 38) + } else { + Circle() + .fill(Color.nightRaised) + .frame(width: 38, height: 38) + } + // Connector line (visual polish) + Rectangle() + .fill(Color.nightBorder) + .frame(width: 1) + .frame(maxHeight: .infinity) + .padding(.top, 8) + } + .frame(width: 38) + + // Right: Content + VStack(alignment: .leading, spacing: 8) { + // Name + Text(isAnonymous ? "anonym" : (appState.currentUser?.displayName ?? "")) + .font(.nightLabel(15, weight: .semibold)) + .foregroundColor(isAnonymous ? .nightSecondary : .nightPrimary) + .italic(isAnonymous) + + // TextEditor with placeholder + ZStack(alignment: .topLeading) { + if text.isEmpty { + Text("Was geht dir gerade durch den Kopf?") + .font(.nightBody(17)) + .foregroundColor(.nightTertiary) + .allowsHitTesting(false) + .padding(.top, 8) + .padding(.leading, 5) + } + TextEditor(text: $text) + .scrollContentBackground(.hidden) + .background(.clear) + .foregroundColor(.nightPrimary) + .font(.nightBody(17)) + .lineSpacing(5) + .frame(minHeight: 160) + .onChange(of: text) { _, new in + if new.count > maxChars { + text = String(new.prefix(maxChars)) + } + } + } + + // Mood picker inline + MoodPickerRow(selected: $selectedMood) + .padding(.top, 4) + + Spacer().frame(height: 20) + } + } + .padding(.horizontal, 16) + .padding(.top, 18) + } + + Spacer() + + // Bottom bar: anonymous toggle + countdown + Divider().background(Color.nightBorder) + + HStack(spacing: 14) { + // Anonymous toggle + Button { + withAnimation(.spring(duration: 0.3, bounce: 0.4)) { + isAnonymous.toggle() + } + } label: { + HStack(spacing: 6) { + Image(systemName: isAnonymous ? "theatermasks.fill" : "theatermasks") + .font(.system(size: 15)) + Text(isAnonymous ? "anonym" : "anonym posten") + .font(.nightLabel(13)) + } + .foregroundColor(isAnonymous ? .nightPrimary : .nightSecondary) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(isAnonymous ? Color.nightRaised : .clear) + .clipShape(Capsule()) + .overlay( + Capsule() + .strokeBorder( + isAnonymous ? Color.nightBorder : .clear, + lineWidth: 1 + ) + ) + } + + Spacer() + + WindowCountdownView() + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .padding(.bottom, 8) + + if let err = errorMessage { + Text(err) + .font(.nightLabel(13)) + .foregroundColor(.nightRed) + .padding(.horizontal, 20) + .padding(.bottom, 8) + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Abbrechen") { dismiss() } + .foregroundColor(.nightSecondary) + .font(.nightBody(16)) + } + ToolbarItem(placement: .navigationBarTrailing) { + PostButton(canPost: canPost, isPosting: isPosting) { + Task { await submit() } + } + } + } + } + .preferredColorScheme(.dark) + } + + var currentTime: String { + let f = DateFormatter(); f.dateFormat = "HH:mm" + return f.string(from: Date()) + } + + func submit() async { + guard let mood = selectedMood else { return } + isPosting = true + errorMessage = nil + defer { isPosting = false } + do { + try await APIService.shared.createPost( + content: text.trimmingCharacters(in: .whitespacesAndNewlines), + mood: mood, + isAnonymous: isAnonymous + ) + appState.markAsPosted() + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} + +// MARK: - Mood Picker + +struct MoodPickerRow: View { + @Binding var selected: Mood? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("stimmung") + .font(.nightLabel(11, weight: .medium)) + .foregroundColor(.nightTertiary) + .kerning(0.8) + + HStack(spacing: 7) { + ForEach(Mood.allCases, id: \.self) { mood in + MoodChip(mood: mood, isSelected: selected == mood) { + withAnimation(.spring(duration: 0.3, bounce: 0.3)) { + selected = selected == mood ? nil : mood + } + } + } + } + } + } +} + +struct MoodChip: View { + let mood: Mood + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 5) { + Text(mood.emoji) + .font(.nightMono(12)) + .foregroundColor(isSelected ? mood.color : .nightSecondary) + Text(mood.label) + .font(.nightLabel(12, weight: isSelected ? .semibold : .regular)) + .foregroundColor(isSelected ? .nightPrimary : .nightSecondary) + } + .padding(.horizontal, 11) + .padding(.vertical, 7) + .background( + ZStack { + if isSelected { + Capsule().fill(mood.color.opacity(0.14)) + Capsule().strokeBorder(mood.color.opacity(0.4), lineWidth: 1) + } else { + Capsule().fill(Color.nightRaised) + Capsule().strokeBorder(Color.nightBorder, lineWidth: 1) + } + } + ) + } + } +} + +// MARK: - Post Button + +struct PostButton: View { + let canPost: Bool + let isPosting: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Group { + if isPosting { + ProgressView().tint(.black).frame(width: 20, height: 20) + } else { + Text("posten") + .font(.nightLabel(15, weight: .bold)) + .foregroundColor(canPost ? Color.nightBase : .nightTertiary) + } + } + .frame(width: 74, height: 34) + .background(canPost ? Color.nightPrimary : Color.nightRaised) + .clipShape(Capsule()) + } + .disabled(!canPost || isPosting) + .animation(.easeInOut(duration: 0.2), value: canPost) + } +} + +// MARK: - Countdown + +struct WindowCountdownView: View { + @State private var label = "" + + var body: some View { + HStack(spacing: 5) { + Image(systemName: "clock") + .font(.system(size: 11)) + Text(label) + .font(.nightMono(11)) + } + .foregroundColor(.nightPurple.opacity(0.5)) + .onAppear { tick() } + .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in tick() } + } + + func tick() { + var c = Calendar.current.dateComponents([.year, .month, .day], from: Date()) + c.hour = 5; c.minute = 0; c.second = 0 + guard let end = Calendar.current.date(from: c) else { return } + let diff = max(0, Int(end.timeIntervalSince(Date()))) + label = diff > 0 + ? String(format: "%d:%02d bis 05:00", diff / 60, diff % 60) + : "fenster zu" + } +} diff --git a/ios/NightThoughts/Views/DiaryView.swift b/ios/NightThoughts/Views/DiaryView.swift new file mode 100644 index 0000000..b3c6b4a --- /dev/null +++ b/ios/NightThoughts/Views/DiaryView.swift @@ -0,0 +1,287 @@ +import SwiftUI + +/// Persönliches Tagebuch — alle eigenen Posts, auch die anonymen. +/// Bleibt für immer. Das ist der Retention-Hook. +struct DiaryView: View { + @StateObject private var viewModel = DiaryViewModel() + @EnvironmentObject var appState: AppState + + var body: some View { + NavigationStack { + ZStack { + Color.nightBase.ignoresSafeArea() + + if viewModel.groupedPosts.isEmpty && !viewModel.isLoading { + DiaryEmptyView() + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + // "Vor genau einem Jahr" — Memory + if let memory = viewModel.yearAgoPost { + MemoryBanner(post: memory) + .padding(.bottom, 4) + } + + ForEach(viewModel.groupedPosts, id: \.nightLabel) { group in + // Nacht-Header + DiaryNightHeader(label: group.nightLabel, count: group.posts.count) + + ForEach(group.posts) { post in + DiaryPostRow(post: post) { + Task { await viewModel.deletePost(post) } + } + Divider().background(Color.nightBorder).padding(.leading, 16) + } + } + Color.clear.frame(height: 100) + } + } + .refreshable { await viewModel.load() } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + HStack(spacing: 7) { + Image(systemName: "book.closed.fill") + .foregroundColor(.nightPurple) + Text("tagebuch") + .font(.nightTitle(17)) + .foregroundColor(.nightPrimary) + } + } + } + } + .task { await viewModel.load() } + } +} + +// MARK: - Memory Banner + +struct MemoryBanner: View { + let post: Post + @State private var show = true + + var body: some View { + if show { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Image(systemName: "clock.arrow.circlepath") + .foregroundColor(.nightPurpleSoft) + .font(.system(size: 14)) + Text("vor genau einem jahr") + .font(.nightLabel(12, weight: .semibold)) + .foregroundColor(.nightPurpleSoft) + .kerning(0.5) + Spacer() + Button { withAnimation { show = false } } label: { + Image(systemName: "xmark") + .font(.system(size: 12)) + .foregroundColor(.nightTertiary) + } + } + + Text(post.content) + .font(.nightBody(15)) + .foregroundColor(.nightPrimary.opacity(0.85)) + .lineSpacing(4) + + HStack(spacing: 5) { + if let mood = post.mood { + Text(mood.emoji).font(.nightMono(11)) + Text(mood.label).font(.nightLabel(11)) + } + Text("·") + Text(post.formattedTime) + .font(.nightMono(11)) + } + .foregroundColor(.nightTertiary) + } + .padding(16) + .background( + ZStack { + RoundedRectangle(cornerRadius: 0) + .fill(Color.nightPurple.opacity(0.06)) + RoundedRectangle(cornerRadius: 0) + .fill( + LinearGradient( + colors: [Color.nightPurple.opacity(0.08), .clear], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + ) + .overlay( + Rectangle().fill(Color.nightBorder).frame(height: 1), + alignment: .bottom + ) + } + } +} + +// MARK: - Night Group Header + +struct DiaryNightHeader: View { + let label: String + let count: Int + + var body: some View { + HStack { + Text(label) + .font(.nightLabel(12, weight: .semibold)) + .foregroundColor(.nightSecondary) + .kerning(0.5) + Text("· \(count) Gedanke\(count == 1 ? "" : "n")") + .font(.nightLabel(12)) + .foregroundColor(.nightTertiary) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color.nightSurface) + } +} + +// MARK: - Diary Post Row + +struct DiaryPostRow: View { + let post: Post + let onDelete: () -> Void + @State private var confirmDelete = false + + var body: some View { + HStack(alignment: .top, spacing: 0) { + Rectangle() + .fill(post.mood?.color ?? Color.nightTertiary) + .frame(width: 2) + .padding(.vertical, 14) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(post.formattedTime) + .font(.nightMono(12)) + .foregroundColor(.nightTertiary) + + if post.isAnonymous { + Text("· anonym") + .font(.nightLabel(11)) + .foregroundColor(.nightTertiary) + .italic() + } + + if let mood = post.mood { + Text("· \(mood.emoji) \(mood.label)") + .font(.nightLabel(11)) + .foregroundColor(mood.color.opacity(0.7)) + } + + Spacer() + + if post.resonanceCount > 0 { + HStack(spacing: 3) { + Image(systemName: "heart.fill") + .font(.system(size: 10)) + Text("\(post.resonanceCount)") + .font(.nightLabel(11)) + } + .foregroundColor(.nightRed.opacity(0.7)) + } + } + + Text(post.content) + .font(.nightBody(16)) + .foregroundColor(.nightPrimary.opacity(0.88)) + .lineSpacing(5) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + .contextMenu { + Button(role: .destructive) { + confirmDelete = true + } label: { + Label("Löschen", systemImage: "trash") + } + } + } + .confirmationDialog( + "Post löschen?", + isPresented: $confirmDelete, + titleVisibility: .visible + ) { + Button("Löschen", role: .destructive) { onDelete() } + } message: { + Text("Der Post wird aus dem Feed entfernt, bleibt aber in deinem Tagebuch-Archiv.") + } + } +} + +// MARK: - Empty State + +struct DiaryEmptyView: View { + var body: some View { + VStack(spacing: 18) { + Image(systemName: "book.closed") + .font(.system(size: 48)) + .foregroundColor(.nightTertiary) + Text("noch nichts hier") + .font(.nightTitle(18)) + .foregroundColor(.nightPrimary) + Text("Deine Posts landen hier.\nIn einem Jahr kannst du nachlesen, was dich\nmitten in der Nacht beschäftigt hat.") + .font(.nightBody(15)) + .foregroundColor(.nightSecondary) + .multilineTextAlignment(.center) + .lineSpacing(4) + } + .padding(40) + } +} + +// MARK: - ViewModel + +@MainActor +class DiaryViewModel: ObservableObject { + struct NightGroup { let nightLabel: String; let posts: [Post] } + + @Published var groupedPosts: [NightGroup] = [] + @Published var yearAgoPost: Post? = nil + @Published var isLoading = false + + func load() async { + isLoading = true + defer { isLoading = false } + + do { + let posts = try await supabase.getDiary() + + // Gruppiere nach Nacht (Datum - 2h, damit 2–5 Uhr zur selben Nacht gehört) + let grouped = Dictionary(grouping: posts) { post -> String in + let nightDate = post.createdAt.addingTimeInterval(-2 * 3600) + let f = DateFormatter() + f.locale = Locale(identifier: "de_DE") + f.dateFormat = "EEEE, d. MMMM yyyy" + return f.string(from: nightDate) + } + + groupedPosts = grouped + .sorted { $0.key > $1.key } + .map { NightGroup(nightLabel: $0.key, posts: $0.value) } + + // Memory: Post von vor genau einem Jahr + let oneYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: Date())! + yearAgoPost = posts.first { post in + Calendar.current.isDate(post.createdAt, equalTo: oneYearAgo, toGranularity: .day) + } + } catch { + #if DEBUG + groupedPosts = [NightGroup(nightLabel: "gestern", posts: Post.previews)] + #endif + } + } + + func deletePost(_ post: Post) async { + try? await supabase.softDeletePost(id: post.id) + await load() + } +} diff --git a/ios/NightThoughts/Views/FeedView.swift b/ios/NightThoughts/Views/FeedView.swift new file mode 100644 index 0000000..3e5fe36 --- /dev/null +++ b/ios/NightThoughts/Views/FeedView.swift @@ -0,0 +1,385 @@ +import SwiftUI + +// MARK: - Feed + +struct FeedView: View { + @StateObject private var viewModel = FeedViewModel() + @EnvironmentObject var appState: AppState + var realtime: RealtimeService? = nil + + // Gerade Jetzt = posts younger than 10 minutes + var rightNowPosts: [Post] { viewModel.posts.filter { $0.isRightNow } } + var nightPosts: [Post] { viewModel.posts } + + var body: some View { + NavigationStack { + ZStack { + Color.nightBase.ignoresSafeArea() + + if viewModel.posts.isEmpty && !viewModel.isLoading { + EmptyNightView(windowState: appState.windowState) + } else { + ScrollView { + LazyVStack(spacing: 0) { + + // ── GERADE JETZT ────────────────────────────────── + // Nur sichtbar wenn: Fenster offen ODER du hast gerade gepostet + // UND es gibt Leute die gleichzeitig posten + if !rightNowPosts.isEmpty && appState.windowState == .posted { + RightNowSection( + posts: rightNowPosts, + onResonate: { post in + Task { await viewModel.resonate(post) } + } + ) + .padding(.bottom, 2) + } + + // ── TRENNLINIE MIT KONTEXT ────────────────────── + NightContextBar( + windowState: appState.windowState, + totalCount: nightPosts.count, + liveCount: rightNowPosts.count + ) + + // ── HEUTE NACHT ──────────────────────────────── + // Alle Posts dieser Nacht, chronologisch + ForEach(nightPosts) { post in + PostRowView(post: post) { + Task { await viewModel.resonate(post) } + } + Divider() + .background(Color.nightBorder) + .padding(.leading, 16) + } + + if viewModel.isLoading { + ProgressView() + .tint(.nightPurple) + .padding(40) + } + + Color.clear.frame(height: 100) + } + } + .refreshable { await viewModel.load() } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + NightlyWordmark() + } + } + } + .task { + await viewModel.load() + if let realtime { + await realtime.startListening { [weak viewModel] post in + viewModel?.prepend(post) + } + } + } + } +} + +// MARK: - Wordmark + +struct NightlyWordmark: View { + var body: some View { + HStack(spacing: 7) { + Text("◐") + .font(.system(size: 15)) + .foregroundColor(.nightPurple) + Text("nightly") + .font(.system(size: 17, weight: .bold, design: .rounded)) + .foregroundColor(.nightPrimary) + } + } +} + +// MARK: - Gerade Jetzt Section +// +// WANN ERSCHEINT DAS? +// → Du hast in den letzten 10 Minuten gepostet +// → Und mindestens eine andere Person auch +// +// WAS IST DER UNTERSCHIED ZU "HEUTE NACHT"? +// → Gerade Jetzt = buchstäblich gerade, gleichzeitig, diese Minute +// → Heute Nacht = alle Posts seit dem Öffnen des Fensters +// +// ANALOGIE: Gerade Jetzt = du bist gerade im selben Raum wie jemand. +// Heute Nacht = der gesamte Raum-Verlauf dieser Nacht. + +struct RightNowSection: View { + let posts: [Post] + let onResonate: (Post) -> Void + + @State private var pulse = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack(spacing: 10) { + // Pulsierender grüner Punkt = LIVE + ZStack { + Circle() + .fill(Color.nightGreen.opacity(0.25)) + .frame(width: 16, height: 16) + .scaleEffect(pulse ? 1.8 : 1.0) + .opacity(pulse ? 0 : 1) + Circle() + .fill(Color.nightGreen) + .frame(width: 7, height: 7) + } + .onAppear { + withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: false)) { + pulse = true + } + } + + Text("gerade jetzt") + .font(.nightLabel(12, weight: .semibold)) + .foregroundColor(.nightGreen) + .kerning(0.8) + + Text("· \(posts.count) \(posts.count == 1 ? "Person" : "Personen") gleichzeitig wach") + .font(.nightLabel(12)) + .foregroundColor(.nightSecondary) + + Spacer() + + // Info-Tooltip + HelpTooltip( + text: "Leute die in den letzten 10 Minuten gepostet haben — ihr seid buchstäblich gleichzeitig wach." + ) + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 12) + + // Horizontal Cards + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(posts) { post in + RightNowCard(post: post, onResonate: { onResonate(post) }) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } + .background( + ZStack { + Color.nightSurface + Color.nightGreen.opacity(0.025) + } + ) + .overlay( + Rectangle() + .fill(Color.nightGreen.opacity(0.15)) + .frame(height: 1), + alignment: .bottom + ) + } +} + +struct RightNowCard: View { + let post: Post + let onResonate: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + if post.isAnonymous { + AnonymousAvatar(size: 26) + } else { + AvatarView(user: post.author, size: 26) + } + Text(post.isAnonymous ? "anonym" : "@\(post.author.username)") + .font(.nightLabel(12)) + .foregroundColor(post.isAnonymous ? .nightSecondary : .nightPrimary) + .lineLimit(1) + Spacer() + if let mood = post.mood { + Text(mood.emoji) + .font(.nightMono(11)) + .foregroundColor(mood.color.opacity(0.7)) + } + } + + Text(post.content) + .font(.nightBody(14)) + .foregroundColor(.nightPrimary.opacity(0.88)) + .lineSpacing(4) + .lineLimit(4) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 0) + + Button(action: onResonate) { + HStack(spacing: 4) { + Image(systemName: post.hasResonated ? "heart.fill" : "heart") + .font(.system(size: 12)) + .foregroundColor(post.hasResonated ? .nightRed : .nightSecondary) + if post.resonanceCount > 0 { + Text("\(post.resonanceCount)") + .font(.nightLabel(11)) + .foregroundColor(.nightSecondary) + } + } + } + } + .padding(14) + .frame(width: 210, height: 148) + .background( + ZStack { + RoundedRectangle(cornerRadius: 14) + .fill(Color.nightRaised) + if let mood = post.mood { + RoundedRectangle(cornerRadius: 14) + .fill( + LinearGradient( + colors: [mood.color.opacity(0.07), .clear], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + RoundedRectangle(cornerRadius: 14) + .strokeBorder(Color.nightGreen.opacity(0.18), lineWidth: 1) + } + ) + } +} + +// MARK: - Context Bar (der Übergang zwischen Gerade Jetzt und Heute Nacht) + +struct NightContextBar: View { + let windowState: AppState.WindowState + let totalCount: Int + let liveCount: Int + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text("heute nacht") + .font(.nightLabel(12, weight: .semibold)) + .foregroundColor(.nightSecondary) + .kerning(0.5) + + if totalCount > 0 { + Text("· \(totalCount) Gedanken") + .font(.nightLabel(12)) + .foregroundColor(.nightTertiary) + } + } + + Text(statusSubtitle) + .font(.nightLabel(11)) + .foregroundColor(.nightTertiary) + } + + Spacer() + + // Window status pill + HStack(spacing: 5) { + Circle() + .fill(windowState == .open ? Color.nightGreen : Color.nightTertiary) + .frame(width: 6, height: 6) + Text(windowState == .open ? "offen" : "geschlossen") + .font(.nightLabel(11)) + .foregroundColor(.nightSecondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.nightRaised) + .clipShape(Capsule()) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .overlay( + Rectangle() + .fill(Color.nightBorder) + .frame(height: 1), + alignment: .bottom + ) + } + + var statusSubtitle: String { + switch windowState { + case .open: return "Du kannst noch posten — bis 05:00" + case .posted: return "Dein Post ist sichtbar bis morgen früh" + case .closed: return "Fenster öffnet später heute Nacht" + case .missed: return "Nächste Chance: heute Nacht" + } + } +} + +// MARK: - Help Tooltip + +struct HelpTooltip: View { + let text: String + @State private var show = false + + var body: some View { + Button { + withAnimation(.spring(duration: 0.3)) { show.toggle() } + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + withAnimation { show = false } + } + } label: { + Image(systemName: "info.circle") + .font(.system(size: 13)) + .foregroundColor(.nightTertiary) + } + .overlay(alignment: .topTrailing) { + if show { + Text(text) + .font(.nightBody(12)) + .foregroundColor(.nightPrimary) + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.nightRaised) + .shadow(color: .black.opacity(0.4), radius: 8, y: 4) + ) + .frame(width: 200) + .offset(x: -160, y: 28) + .transition(.opacity.combined(with: .scale(scale: 0.9, anchor: .topTrailing))) + .zIndex(100) + } + } + } +} + +// MARK: - Empty State + +struct EmptyNightView: View { + let windowState: AppState.WindowState + + var body: some View { + VStack(spacing: 20) { + Text("◐") + .font(.system(size: 52)) + .foregroundColor(.nightPurple.opacity(0.4)) + + VStack(spacing: 8) { + Text(windowState == .open ? "sei der erste heute nacht" : "noch ruhig hier") + .font(.nightTitle(19)) + .foregroundColor(.nightPrimary) + + Text(windowState == .open + ? "Das Fenster ist offen.\nPoste einen Gedanken — andere sind auch wach." + : "Wenn dein Fenster öffnet, kannst du posten.\nErst dann siehst du alle anderen." + ) + .font(.nightBody(15)) + .foregroundColor(.nightSecondary) + .multilineTextAlignment(.center) + .lineSpacing(4) + } + } + .padding(40) + } +} diff --git a/ios/NightThoughts/Views/LegalView.swift b/ios/NightThoughts/Views/LegalView.swift new file mode 100644 index 0000000..182ecb5 --- /dev/null +++ b/ios/NightThoughts/Views/LegalView.swift @@ -0,0 +1,232 @@ +import SwiftUI + +/// Impressum + Datenschutzerklärung +/// ⚠️ Muss vor dem Launch von einem Anwalt geprüft/ergänzt werden! +/// Kosten: ca. 300–500€ einmalig für Impressum + AGB + DSGVO-Datenschutzerklärung +struct LegalView: View { + @Environment(\.dismiss) var dismiss + @State private var tab = 0 + + var body: some View { + NavigationStack { + ZStack { + Color.nightBase.ignoresSafeArea() + + VStack(spacing: 0) { + // Tab Switcher + HStack(spacing: 0) { + ForEach(["impressum", "datenschutz", "nutzungsbedingungen"], id: \.self) { label in + let idx = ["impressum", "datenschutz", "nutzungsbedingungen"].firstIndex(of: label)! + Button(label) { tab = idx } + .font(.nightLabel(12, weight: tab == idx ? .semibold : .regular)) + .foregroundColor(tab == idx ? .nightPrimary : .nightSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .overlay( + Rectangle() + .fill(tab == idx ? Color.nightPurple : .clear) + .frame(height: 2), + alignment: .bottom + ) + } + } + .padding(.horizontal, 16) + .overlay(Rectangle().fill(Color.nightBorder).frame(height: 1), alignment: .bottom) + + ScrollView { + VStack(alignment: .leading, spacing: 0) { + switch tab { + case 0: ImpressumContent() + case 1: DatenschutzContent() + default: NutzungsbedingungenContent() + } + } + .padding(20) + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Schließen") { dismiss() } + .foregroundColor(.nightSecondary) + } + } + } + .preferredColorScheme(.dark) + } +} + +// MARK: - Impressum + +struct ImpressumContent: View { + var body: some View { + LegalSection(title: "Impressum") { + // ⚠️ PFLICHTANGABEN — vor Launch ausfüllen! + LegalParagraph(title: "Angaben gemäß § 5 TMG") { + """ + [DEIN NAME] + [STRASSE HAUSNUMMER] + [PLZ ORT] + Deutschland + + ⚠️ Vor Launch ausfüllen! + """ + } + LegalParagraph(title: "Kontakt") { + """ + E-Mail: legal@xxx.dk0.dev + + ⚠️ Vor Launch ausfüllen! + """ + } + LegalParagraph(title: "Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV") { + "[DEIN NAME], [ADRESSE]" + } + LegalParagraph(title: "Hinweis") { + """ + Diese App ist ein privates Projekt. Für die Richtigkeit, \ + Vollständigkeit und Aktualität der Inhalte kann keine Gewähr übernommen werden. + """ + } + } + } +} + +// MARK: - Datenschutz + +struct DatenschutzContent: View { + var body: some View { + LegalSection(title: "Datenschutzerklärung") { + LegalParagraph(title: "⚠️ Hinweis") { + """ + Diese Datenschutzerklärung ist ein Entwurf und muss vor dem Launch \ + von einem Datenschutzanwalt geprüft und vervollständigt werden. \ + Kosten: ca. 300–500€. + """ + } + LegalParagraph(title: "Verantwortlicher") { + "[DEIN NAME], [ADRESSE], [E-MAIL]" + } + LegalParagraph(title: "Welche Daten wir speichern") { + """ + • E-Mail-Adresse (für Account & Passwort-Reset) + • Benutzername und Anzeigename + • Posts, Reaktionen, Kommentare (Inhalte die du selbst erstellst) + • Push-Token (für Benachrichtigungen, optional) + • IP-Adresse in Server-Logs (max. 14 Tage) + """ + } + LegalParagraph(title: "Wofür wir Daten verwenden") { + """ + • Betrieb des Dienstes (Authentifizierung, Feed, Benachrichtigungen) + • Moderation (Meldungen von Inhalten) + • Keine Weitergabe an Dritte außer für den Betrieb notwendige Dienste + """ + } + LegalParagraph(title: "Serverstandort") { + "Alle Daten werden auf Servern in der EU gespeichert." + } + LegalParagraph(title: "Deine Rechte (DSGVO)") { + """ + • Auskunft über gespeicherte Daten: legal@xxx.dk0.dev + • Berichtigung falscher Daten + • Löschung: Account in den Einstellungen löschen — entfernt alle deine Daten sofort + • Datenübertragbarkeit: auf Anfrage per E-Mail + • Widerspruch gegen Verarbeitung: legal@xxx.dk0.dev + • Beschwerde bei der Datenschutzbehörde + """ + } + LegalParagraph(title: "Datenlöschung") { + """ + Posts werden 14 Stunden nach Erstellung aus dem öffentlichen Feed entfernt. \ + Dein persönliches Tagebuch behältst du so lange du möchtest. \ + Account-Löschung entfernt alle Daten dauerhaft und unwiderruflich. + """ + } + LegalParagraph(title: "Cookies / Tracking") { + "Wir verwenden keine Cookies, keine Tracker, keine Werbenetze." + } + } + } +} + +// MARK: - Nutzungsbedingungen + +struct NutzungsbedingungenContent: View { + var body: some View { + LegalSection(title: "Nutzungsbedingungen") { + LegalParagraph(title: "⚠️ Entwurf") { + "Diese Nutzungsbedingungen sind ein Entwurf und müssen vor dem Launch von einem Anwalt geprüft werden." + } + LegalParagraph(title: "Nutzung") { + """ + nightly ist ein Dienst für Personen ab 17 Jahren. \ + Du bist für die Inhalte die du postest selbst verantwortlich. + """ + } + LegalParagraph(title: "Verbotene Inhalte") { + """ + Folgende Inhalte sind verboten: + • Hassrede, Diskriminierung, Bedrohung + • Belästigung oder Mobbing + • Illegale Inhalte jeglicher Art + • Spam oder kommerzielle Werbung + • Inhalte die andere Personen ohne deren Zustimmung zeigen + """ + } + LegalParagraph(title: "Moderation") { + """ + Gemeldete Inhalte werden geprüft und können ohne Vorankündigung entfernt werden. \ + Bei schwerwiegenden Verstößen behalten wir uns die Sperrung des Accounts vor. + """ + } + LegalParagraph(title: "Haftungsausschluss") { + """ + Wir übernehmen keine Haftung für nutzergenerierte Inhalte. \ + Der Dienst wird ohne Gewähr für Verfügbarkeit bereitgestellt. + """ + } + } + } +} + +// MARK: - Reusable components + +struct LegalSection: View { + let title: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Text(title) + .font(.nightTitle(22)) + .foregroundColor(.nightPrimary) + .padding(.bottom, 4) + content + } + } +} + +struct LegalParagraph: View { + let title: String + let body: String + + init(title: String, _ body: () -> String) { + self.title = title + self.body = body() + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.nightLabel(14, weight: .semibold)) + .foregroundColor(.nightPrimary) + Text(body) + .font(.nightBody(14)) + .foregroundColor(.nightSecondary) + .lineSpacing(4) + .fixedSize(horizontal: false, vertical: true) + } + } +} diff --git a/ios/NightThoughts/Views/MainTabView.swift b/ios/NightThoughts/Views/MainTabView.swift new file mode 100644 index 0000000..0db34df --- /dev/null +++ b/ios/NightThoughts/Views/MainTabView.swift @@ -0,0 +1,199 @@ +import SwiftUI + +struct MainTabView: View { + @EnvironmentObject var appState: AppState + @StateObject private var realtime = RealtimeService() + @State private var selectedTab = 0 + @State private var showCompose = false + @State private var showSettings = false + + var body: some View { + ZStack(alignment: .bottom) { + Color.nightBase.ignoresSafeArea() + + // Content + TabContent( + selectedTab: selectedTab, + realtime: realtime + ) + .environmentObject(appState) + + // Floating Tab Bar + FloatingTabBar( + selectedTab: $selectedTab, + windowState: appState.windowState, + onCompose: { showCompose = true }, + onSettings: { showSettings = true } + ) + } + .ignoresSafeArea(edges: .bottom) + .sheet(isPresented: $showCompose) { + ComposeView().environmentObject(appState) + } + .sheet(isPresented: $showSettings) { + SettingsView().environmentObject(appState) + } + .onDisappear { + Task { await realtime.stopListening() } + } + } +} + +// MARK: - Tab Content + +private struct TabContent: View { + let selectedTab: Int + @ObservedObject var realtime: RealtimeService + @EnvironmentObject var appState: AppState + + var body: some View { + ZStack { + FeedView(realtime: realtime) + .environmentObject(appState) + .opacity(selectedTab == 0 ? 1 : 0) + .allowsHitTesting(selectedTab == 0) + + DiaryView() + .environmentObject(appState) + .opacity(selectedTab == 1 ? 1 : 0) + .allowsHitTesting(selectedTab == 1) + + ProfileView( + user: appState.currentUser ?? .preview, + isCurrentUser: true + ) + .environmentObject(appState) + .opacity(selectedTab == 2 ? 1 : 0) + .allowsHitTesting(selectedTab == 2) + } + } +} + +// MARK: - Floating Tab Bar + +struct FloatingTabBar: View { + @Binding var selectedTab: Int + let windowState: AppState.WindowState + let onCompose: () -> Void + let onSettings: () -> Void + + var body: some View { + HStack(spacing: 0) { + // Feed + TabIcon(icon: "moon.stars", activeIcon: "moon.stars.fill", isSelected: selectedTab == 0) { + selectedTab = 0 + } + + Spacer() + + // Diary + TabIcon(icon: "book.closed", activeIcon: "book.closed.fill", isSelected: selectedTab == 1) { + selectedTab = 1 + } + + Spacer() + + // Center: Compose + ComposeTabButton(windowState: windowState, onTap: onCompose) + + Spacer() + + // Profile + TabIcon(icon: "person", activeIcon: "person.fill", isSelected: selectedTab == 2) { + selectedTab = 2 + } + + Spacer() + + // Settings + TabIcon(icon: "gearshape", activeIcon: "gearshape.fill", isSelected: false) { + onSettings() + } + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .padding(.bottom, 18) + .background( + Rectangle() + .fill(.ultraThinMaterial.opacity(0.8)) + .background(Color.nightBase.opacity(0.85)) + .ignoresSafeArea() + ) + .overlay( + Rectangle().fill(Color.nightBorder).frame(height: 1), + alignment: .top + ) + } +} + +struct TabIcon: View { + let icon: String + let activeIcon: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: isSelected ? activeIcon : icon) + .font(.system(size: 21)) + .foregroundColor(isSelected ? .nightPrimary : .nightSecondary) + .frame(width: 44, height: 44) + } + } +} + +struct ComposeTabButton: View { + let windowState: AppState.WindowState + let onTap: () -> Void + @State private var glow = false + + var body: some View { + Button { + guard windowState == .open else { return } + onTap() + } label: { + ZStack { + if windowState == .open { + Circle() + .fill(Color.nightPurple.opacity(0.18)) + .frame(width: 62, height: 62) + .scaleEffect(glow ? 1.15 : 1.0) + .animation(.easeInOut(duration: 2.2).repeatForever(autoreverses: true), value: glow) + } + Circle() + .fill(buttonFill) + .frame(width: 50, height: 50) + Image(systemName: buttonIcon) + .font(.system(size: 19, weight: .semibold)) + .foregroundColor(.white) + } + } + .onAppear { glow = true } + .animation(.easeInOut(duration: 0.4), value: windowState) + } + + var buttonFill: AnyShapeStyle { + switch windowState { + case .open: + return AnyShapeStyle(LinearGradient( + colors: [Color(hex: "8B5CF6"), Color(hex: "6D28D9")], + startPoint: .topLeading, endPoint: .bottomTrailing + )) + case .posted: + return AnyShapeStyle(LinearGradient( + colors: [Color(hex: "059669"), Color(hex: "047857")], + startPoint: .top, endPoint: .bottom + )) + default: + return AnyShapeStyle(Color.nightRaised) + } + } + + var buttonIcon: String { + switch windowState { + case .open: return "plus" + case .posted: return "checkmark" + default: return "moon.zzz" + } + } +} diff --git a/ios/NightThoughts/Views/OnboardingView.swift b/ios/NightThoughts/Views/OnboardingView.swift new file mode 100644 index 0000000..d6ec48f --- /dev/null +++ b/ios/NightThoughts/Views/OnboardingView.swift @@ -0,0 +1,306 @@ +import SwiftUI + +struct OnboardingView: View { + @EnvironmentObject var appState: AppState + @State private var phase: Phase = .welcome + @State private var isLogin = false + + enum Phase { case welcome, auth } + + var body: some View { + ZStack { + Color.nightBase.ignoresSafeArea() + StarField() + + VStack(spacing: 0) { + Spacer() + + switch phase { + case .welcome: + WelcomeScreen() + .transition(.opacity) + Spacer() + WelcomeActions( + onStart: { + isLogin = false + withAnimation(.spring(duration: 0.4)) { phase = .auth } + }, + onLogin: { + isLogin = true + withAnimation(.spring(duration: 0.4)) { phase = .auth } + } + ) + + case .auth: + AuthScreen(isLogin: $isLogin) + .environmentObject(appState) + .transition(.move(edge: .trailing).combined(with: .opacity)) + Spacer() + } + } + } + .preferredColorScheme(.dark) + } +} + +// MARK: - Welcome + +struct WelcomeScreen: View { + @State private var appeared = false + + var body: some View { + VStack(spacing: 28) { + ZStack { + ForEach([130, 100, 70], id: \.self) { size in + Circle() + .fill(Color.nightPurple.opacity(size == 70 ? 0 : (size == 100 ? 0.08 : 0.04))) + .frame(width: CGFloat(size), height: CGFloat(size)) + } + Text("◐") + .font(.system(size: 52)) + .foregroundColor(.nightPurpleSoft) + } + .scaleEffect(appeared ? 1 : 0.75) + .opacity(appeared ? 1 : 0) + + VStack(spacing: 12) { + Text("nightly") + .font(.system(size: 44, weight: .bold, design: .rounded)) + .foregroundColor(.nightPrimary) + + VStack(spacing: 5) { + Text("Zwischen 2 und 5 Uhr.") + Text("Kein Filter. Keine Maske.") + Text("Nur echte Gedanken.") + } + .font(.nightBody(17)) + .foregroundColor(.nightSecondary) + .multilineTextAlignment(.center) + .lineSpacing(3) + } + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 10) + } + .onAppear { + withAnimation(.spring(duration: 0.8, bounce: 0.25).delay(0.1)) { appeared = true } + } + } +} + +struct WelcomeActions: View { + let onStart: () -> Void + let onLogin: () -> Void + + var body: some View { + VStack(spacing: 12) { + Button("loslegen", action: onStart).buttonStyle(NightlyPrimaryButton()) + Button("ich hab schon einen account", action: onLogin) + .font(.nightLabel(14)) + .foregroundColor(.nightSecondary) + } + .padding(.horizontal, 24) + .padding(.bottom, 52) + } +} + +// MARK: - Auth Screen + +struct AuthScreen: View { + @EnvironmentObject var appState: AppState + @Binding var isLogin: Bool + + // Registrierung + @State private var username = "" + @State private var displayName = "" + @State private var email = "" + @State private var password = "" + + @State private var isLoading = false + @State private var error: String? + + var body: some View { + VStack(spacing: 22) { + Text(isLogin ? "willkommen zurück" : "mitmachen") + .font(.nightTitle(28)) + .foregroundColor(.nightPrimary) + + VStack(spacing: 10) { + if !isLogin { + NightlyField(text: $displayName, placeholder: "anzeigename", icon: "person") + NightlyField(text: $username, placeholder: "benutzername", icon: "at") + .textInputAutocapitalization(.never).autocorrectionDisabled() + } + + NightlyField(text: $email, placeholder: "e-mail", icon: "envelope") + .textInputAutocapitalization(.never) + .keyboardType(.emailAddress) + .autocorrectionDisabled() + + NightlyField(text: $password, placeholder: "passwort", icon: "lock", isSecure: true) + } + .padding(.horizontal, 24) + + if let err = error { + Text(err) + .font(.nightLabel(13)) + .foregroundColor(.nightRed) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + } + + Button(isLogin ? "einloggen" : "account erstellen") { + Task { await submit() } + } + .buttonStyle(NightlyPrimaryButton(isLoading: isLoading)) + .disabled(isLoading) + .padding(.horizontal, 24) + + Button(isLogin ? "noch kein account?" : "schon dabei?") { + withAnimation { isLogin.toggle() } + } + .font(.nightLabel(14)) + .foregroundColor(.nightSecondary) + + // Rechtliches + LegalNotice() + } + } + + func submit() async { + guard !email.isEmpty && !password.isEmpty else { + error = "Bitte alle Felder ausfüllen." + return + } + isLoading = true + error = nil + defer { isLoading = false } + + do { + if isLogin { + try await appState.signIn(email: email, password: password) + } else { + guard !username.isEmpty && !displayName.isEmpty else { + error = "Bitte alle Felder ausfüllen." + return + } + guard username.count >= 3 else { + error = "Benutzername muss mindestens 3 Zeichen haben." + return + } + guard password.count >= 8 else { + error = "Passwort muss mindestens 8 Zeichen haben." + return + } + try await appState.signUp( + email: email, + password: password, + username: username.lowercased(), + displayName: displayName + ) + } + } catch { + self.error = error.localizedDescription + } + } +} + +struct LegalNotice: View { + @State private var showLegal = false + + var body: some View { + VStack(spacing: 4) { + Text("Mit der Registrierung stimmst du zu:") + .font(.nightLabel(11)) + .foregroundColor(.nightTertiary) + + HStack(spacing: 4) { + Button("Nutzungsbedingungen") { showLegal = true } + Text("·") + Button("Datenschutzerklärung") { showLegal = true } + } + .font(.nightLabel(11, weight: .medium)) + .foregroundColor(.nightSecondary) + } + .multilineTextAlignment(.center) + .sheet(isPresented: $showLegal) { + LegalView() + } + } +} + +// MARK: - Reusable components + +struct NightlyField: View { + @Binding var text: String + let placeholder: String + let icon: String + var isSecure = false + + var body: some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 15)) + .foregroundColor(.nightSecondary) + .frame(width: 18) + + Group { + if isSecure { SecureField(placeholder, text: $text) } + else { TextField(placeholder, text: $text) } + } + .font(.nightBody(16)) + .foregroundColor(.nightPrimary) + } + .padding(16) + .background(Color.nightSurface) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Color.nightBorder, lineWidth: 1)) + .tint(.nightPurpleSoft) + } +} + +struct NightlyPrimaryButton: ButtonStyle { + var isLoading = false + func makeBody(configuration: Configuration) -> some View { + Group { + if isLoading { ProgressView().tint(.nightBase).frame(maxWidth: .infinity, maxHeight: 52) } + else { + configuration.label + .font(.nightLabel(17, weight: .semibold)) + .foregroundColor(.nightBase) + .frame(maxWidth: .infinity).frame(height: 52) + } + } + .background(Color.nightPrimary.opacity(configuration.isPressed ? 0.85 : 1)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .scaleEffect(configuration.isPressed ? 0.98 : 1) + .animation(.spring(duration: 0.2), value: configuration.isPressed) + } +} + +struct StarField: View { + struct Star: Identifiable { + let id: Int; let x, y, size, opacity: CGFloat + } + private let stars: [Star] = (0..<120).map { + Star(id: $0, + x: .random(in: 0...1), + y: .random(in: 0...1), + size: .random(in: 1...2.5), + opacity: .random(in: 0.07...0.3)) + } + @State private var twinkle = false + var body: some View { + GeometryReader { geo in + ForEach(stars) { s in + Circle().fill(Color.white) + .frame(width: s.size, height: s.size) + .opacity(twinkle && s.id % 5 == 0 ? s.opacity * 0.3 : s.opacity) + .position(x: s.x * geo.size.width, y: s.y * geo.size.height) + } + } + .ignoresSafeArea() + .onAppear { + withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) { twinkle = true } + } + } +} diff --git a/ios/NightThoughts/Views/PostRowView.swift b/ios/NightThoughts/Views/PostRowView.swift new file mode 100644 index 0000000..9e4dfab --- /dev/null +++ b/ios/NightThoughts/Views/PostRowView.swift @@ -0,0 +1,291 @@ +import SwiftUI + +// MARK: - Post Row + +struct PostRowView: View { + let post: Post + let onResonate: () -> Void + var onReport: (() -> Void)? = nil + + @State private var showReport = false + + var body: some View { + HStack(alignment: .top, spacing: 0) { + // Mood accent bar — der einzige echte Farbakzent im Feed + Rectangle() + .fill(post.mood?.color ?? Color.nightTertiary) + .frame(width: 2) + .padding(.vertical, 18) + + VStack(alignment: .leading, spacing: 11) { + // Author + HStack(spacing: 9) { + if post.isAnonymous { + AnonymousAvatar(size: 32) + } else { + AvatarView(user: post.author, size: 32) + } + + VStack(alignment: .leading, spacing: 1) { + if post.isAnonymous { + Text("anonym") + .font(.nightLabel(13)) + .foregroundColor(.nightSecondary) + .italic() + } else { + Text(post.author.displayName) + .font(.nightLabel(14, weight: .semibold)) + .foregroundColor(.nightPrimary) + } + } + + Spacer() + + HStack(spacing: 8) { + if let mood = post.mood { + Text(mood.emoji) + .font(.nightMono(11)) + .foregroundColor(mood.color.opacity(0.8)) + } + Text(post.formattedTime) + .font(.nightMono(11)) + .foregroundColor(.nightTertiary) + + // Drei-Punkte-Menü für Report + Menu { + Button(role: .destructive) { + showReport = true + } label: { + Label("Melden", systemImage: "flag") + } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 13)) + .foregroundColor(.nightTertiary) + .padding(4) + } + } + } + + // Content + Text(post.content) + .font(.nightBody(16)) + .foregroundColor(.nightPrimary.opacity(0.9)) + .lineSpacing(5) + .fixedSize(horizontal: false, vertical: true) + + // Resonance + ResonanceButton( + count: post.resonanceCount, + isActive: post.hasResonated, + action: onResonate + ) + } + .padding(.leading, 14) + .padding(.trailing, 16) + .padding(.vertical, 16) + } + .sheet(isPresented: $showReport) { + ReportSheet(postId: post.id) + } + } +} + +// MARK: - Resonance Button + +struct ResonanceButton: View { + let count: Int + let isActive: Bool + let action: () -> Void + + @State private var scale: CGFloat = 1.0 + + var body: some View { + Button { + withAnimation(.spring(duration: 0.25, bounce: 0.7)) { scale = 1.4 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + withAnimation(.spring(duration: 0.2)) { scale = 1.0 } + } + action() + } label: { + HStack(spacing: 5) { + Image(systemName: isActive ? "heart.fill" : "heart") + .font(.system(size: 14)) + .foregroundColor(isActive ? .nightRed : .nightSecondary) + .scaleEffect(scale) + + Text(count > 0 ? "\(count)" : "hat mich getroffen") + .font(.nightLabel(13)) + .foregroundColor(isActive ? .nightRed : .nightSecondary) + } + .padding(.vertical, 5) + .padding(.horizontal, count > 0 || isActive ? 10 : 0) + .background( + Capsule() + .fill(isActive ? Color.nightRed.opacity(0.1) : Color.clear) + ) + } + .animation(.easeInOut(duration: 0.2), value: isActive) + } +} + +// MARK: - Avatar + +struct AvatarView: View { + let user: User + let size: CGFloat + + var body: some View { + Group { + if let url = user.avatarURL { + AsyncImage(url: url) { img in img.resizable().scaledToFill() } + placeholder: { initials } + } else { initials } + } + .frame(width: size, height: size) + .clipShape(Circle()) + } + + var initials: some View { + ZStack { + Circle().fill(Color.nightPurple.opacity(0.18)) + Text(String(user.displayName.prefix(1)).uppercased()) + .font(.system(size: size * 0.38, weight: .semibold)) + .foregroundColor(.nightPurpleSoft) + } + } +} + +struct AnonymousAvatar: View { + let size: CGFloat + var body: some View { + ZStack { + Circle().fill(Color.nightRaised) + Image(systemName: "questionmark") + .font(.system(size: size * 0.35, weight: .semibold)) + .foregroundColor(.nightSecondary) + } + .frame(width: size, height: size) + } +} + +// MARK: - Report Sheet + +struct ReportSheet: View { + let postId: String + @Environment(\.dismiss) var dismiss + @State private var selected: ReportReason? = nil + @State private var submitted = false + @State private var isLoading = false + + enum ReportReason: String, CaseIterable { + case hate = "Hassrede / Diskriminierung" + case harassment = "Belästigung / Mobbing" + case selfharm = "Selbstverletzung / Suizid" + case illegal = "Illegale Inhalte" + case spam = "Spam" + case other = "Sonstiges" + } + + var body: some View { + NavigationStack { + ZStack { + Color.nightSurface.ignoresSafeArea() + + if submitted { + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 48)) + .foregroundColor(.nightGreen) + Text("Danke für deine Meldung") + .font(.nightTitle(18)) + .foregroundColor(.nightPrimary) + Text("Wir prüfen den Inhalt so schnell wie möglich.") + .font(.nightBody(14)) + .foregroundColor(.nightSecondary) + .multilineTextAlignment(.center) + Button("Schließen") { dismiss() } + .foregroundColor(.nightPurpleSoft) + .padding(.top, 8) + } + .padding(40) + } else { + VStack(alignment: .leading, spacing: 0) { + Text("Warum möchtest du das melden?") + .font(.nightTitle(17)) + .foregroundColor(.nightPrimary) + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 16) + + ForEach(ReportReason.allCases, id: \.self) { reason in + Button { + selected = reason + } label: { + HStack { + Text(reason.rawValue) + .font(.nightBody(15)) + .foregroundColor(.nightPrimary) + Spacer() + if selected == reason { + Image(systemName: "checkmark") + .foregroundColor(.nightPurpleSoft) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + .background(selected == reason ? Color.nightPurple.opacity(0.08) : Color.clear) + } + Divider().background(Color.nightBorder) + } + + Spacer() + + Button { + guard let reason = selected else { return } + Task { await submit(reason: reason) } + } label: { + Group { + if isLoading { + ProgressView().tint(.black) + } else { + Text("Melden") + .font(.nightLabel(16, weight: .semibold)) + .foregroundColor(.black) + } + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(selected != nil ? Color.nightPrimary : Color.nightRaised) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .disabled(selected == nil || isLoading) + .padding(.horizontal, 20) + .padding(.bottom, 32) + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Abbrechen") { dismiss() } + .foregroundColor(.nightSecondary) + } + } + } + .presentationDetents([.medium]) + .preferredColorScheme(.dark) + } + + func submit(reason: ReportReason) async { + isLoading = true + defer { isLoading = false } + do { + try await supabase.reportPost(postId: postId, reason: reason.rawValue, details: nil) + submitted = true + } catch { + // Fehler still ignorieren — Meldung trotzdem als abgeschlossen zeigen + submitted = true + } + } +} diff --git a/ios/NightThoughts/Views/ProfileView.swift b/ios/NightThoughts/Views/ProfileView.swift new file mode 100644 index 0000000..3507590 --- /dev/null +++ b/ios/NightThoughts/Views/ProfileView.swift @@ -0,0 +1,214 @@ +import SwiftUI + +struct ProfileView: View { + let user: User + let isCurrentUser: Bool + @EnvironmentObject var appState: AppState + @StateObject private var viewModel: ProfileViewModel + @State private var showSettings = false + + init(user: User, isCurrentUser: Bool) { + self.user = user + self.isCurrentUser = isCurrentUser + _viewModel = StateObject(wrappedValue: ProfileViewModel(userIdString: user.id)) + } + + var body: some View { + NavigationStack { + ZStack { + Color.nightBase.ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + ProfileHeader( + user: user, + streak: viewModel.streak, + isCurrentUser: isCurrentUser + ) + + Divider().background(Color.nightBorder) + + // Posts + if viewModel.isLoading { + ProgressView().tint(.nightPurple).padding(40) + } else if viewModel.posts.isEmpty { + EmptyProfilePosts() + } else { + LazyVStack(spacing: 0) { + ForEach(viewModel.posts) { post in + PostRowView(post: post) {} + Divider().background(Color.nightBorder).padding(.leading, 16) + } + } + } + + Color.clear.frame(height: 100) + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if isCurrentUser { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showSettings = true + } label: { + Image(systemName: "gearshape") + .foregroundColor(.nightSecondary) + } + } + } + } + .sheet(isPresented: $showSettings) { + SettingsView().environmentObject(appState) + } + } + .task { await viewModel.load() } + } +} + +// MARK: - Profile Header + +struct ProfileHeader: View { + let user: User + let streak: Int + let isCurrentUser: Bool + + @State private var isFollowing: Bool + @State private var isFollowLoading = false + + init(user: User, streak: Int, isCurrentUser: Bool) { + self.user = user + self.streak = streak + self.isCurrentUser = isCurrentUser + _isFollowing = State(initialValue: user.isFollowing) + } + + var body: some View { + VStack(spacing: 0) { + VStack(spacing: 16) { + AvatarView(user: user, size: 76) + + VStack(spacing: 4) { + Text(user.displayName) + .font(.nightTitle(22)) + .foregroundColor(.nightPrimary) + Text("@\(user.username)") + .font(.nightLabel(14)) + .foregroundColor(.nightSecondary) + if let bio = user.bio { + Text(bio) + .font(.nightBody(14)) + .foregroundColor(.nightPrimary.opacity(0.75)) + .multilineTextAlignment(.center) + .padding(.top, 4) + } + } + + // Stats + HStack(spacing: 36) { + ProfileStat(value: user.postCount, label: "nächte") + ProfileStat(value: user.followerCount, label: "follower") + ProfileStat(value: user.followingCount, label: "following") + } + + // Streak + if streak > 0 { + HStack(spacing: 6) { + Image(systemName: streak >= 7 ? "flame.fill" : "flame") + .foregroundColor(streak >= 7 ? .orange : .nightSecondary) + Text("\(streak) Nächte in Folge") + .font(.nightLabel(13, weight: streak >= 7 ? .semibold : .regular)) + .foregroundColor(streak >= 7 ? .nightPrimary : .nightSecondary) + } + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(Color.nightRaised) + .clipShape(Capsule()) + } + + // Action button + if isCurrentUser { + Button("profil bearbeiten") {} + .font(.nightLabel(14, weight: .medium)) + .foregroundColor(.nightPrimary) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(Color.nightRaised) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.nightBorder, lineWidth: 1) + ) + .padding(.horizontal, 48) + } else { + Button { + Task { await toggleFollow() } + } label: { + Group { + if isFollowLoading { ProgressView().tint(isFollowing ? .nightPrimary : .nightBase) } + else { + Text(isFollowing ? "entfolgen" : "folgen") + .font(.nightLabel(14, weight: .semibold)) + .foregroundColor(isFollowing ? .nightPrimary : .nightBase) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(isFollowing ? Color.nightRaised : Color.nightPrimary) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .disabled(isFollowLoading) + .padding(.horizontal, 48) + } + } + .padding(.horizontal, 20) + .padding(.top, 28) + .padding(.bottom, 20) + } + } + + func toggleFollow() async { + isFollowLoading = true + defer { isFollowLoading = false } + guard let uid = UUID(uuidString: user.id) else { return } + do { + if isFollowing { + try await supabase.unfollow(userId: uid) + } else { + try await supabase.follow(userId: uid) + } + isFollowing.toggle() + } catch { /* handle error */ } + } +} + +struct ProfileStat: View { + let value: Int + let label: String + + var body: some View { + VStack(spacing: 3) { + Text("\(value)") + .font(.nightTitle(18)) + .foregroundColor(.nightPrimary) + Text(label) + .font(.nightLabel(12)) + .foregroundColor(.nightSecondary) + } + } +} + +struct EmptyProfilePosts: View { + var body: some View { + VStack(spacing: 14) { + Image(systemName: "moon.zzz") + .font(.system(size: 36)) + .foregroundColor(.nightTertiary) + Text("noch keine nächte") + .font(.nightLabel(15)) + .foregroundColor(.nightSecondary) + } + .padding(.top, 60) + } +} diff --git a/ios/NightThoughts/Views/RootView.swift b/ios/NightThoughts/Views/RootView.swift new file mode 100644 index 0000000..899171f --- /dev/null +++ b/ios/NightThoughts/Views/RootView.swift @@ -0,0 +1,16 @@ +import SwiftUI + +struct RootView: View { + @EnvironmentObject var appState: AppState + + var body: some View { + Group { + if appState.isAuthenticated { + MainTabView() + } else { + OnboardingView() + } + } + .animation(.easeInOut(duration: 0.3), value: appState.isAuthenticated) + } +} diff --git a/ios/NightThoughts/Views/SettingsView.swift b/ios/NightThoughts/Views/SettingsView.swift new file mode 100644 index 0000000..87ade34 --- /dev/null +++ b/ios/NightThoughts/Views/SettingsView.swift @@ -0,0 +1,243 @@ +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject var appState: AppState + @Environment(\.dismiss) var dismiss + @State private var showLegal = false + @State private var showDeleteConfirm = false + @State private var showDeleteFinal = false + @State private var deletePassword = "" + @State private var isDeleting = false + @State private var deleteError: String? + @State private var notificationsEnabled = false + + var body: some View { + NavigationStack { + ZStack { + Color.nightBase.ignoresSafeArea() + + List { + // Account + Section { + if let user = appState.currentUser { + HStack(spacing: 12) { + AvatarView(user: user, size: 44) + VStack(alignment: .leading, spacing: 2) { + Text(user.displayName) + .font(.nightLabel(15, weight: .semibold)) + .foregroundColor(.nightPrimary) + Text("@\(user.username)") + .font(.nightLabel(13)) + .foregroundColor(.nightSecondary) + } + } + .padding(.vertical, 4) + } + } + .listRowBackground(Color.nightSurface) + + // Benachrichtigungen + Section("benachrichtigungen") { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("nightly ping") + .font(.nightLabel(15)) + .foregroundColor(.nightPrimary) + Text("Wenn das Fenster öffnet") + .font(.nightLabel(12)) + .foregroundColor(.nightSecondary) + } + Spacer() + Toggle("", isOn: $notificationsEnabled) + .tint(.nightPurple) + } + + // APNs-Hinweis + HStack(spacing: 8) { + Image(systemName: "info.circle") + .foregroundColor(.nightSecondary) + .font(.system(size: 13)) + Text("Push-Benachrichtigungen benötigen einen bezahlten Apple Developer Account ($99/Jahr).") + .font(.nightLabel(12)) + .foregroundColor(.nightSecondary) + .lineSpacing(3) + } + .padding(.vertical, 2) + } + .listRowBackground(Color.nightSurface) + + // Rechtliches + Section("rechtliches") { + Button { + showLegal = true + } label: { + HStack { + Text("Impressum & Datenschutz") + .font(.nightLabel(15)) + .foregroundColor(.nightPrimary) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12)) + .foregroundColor(.nightSecondary) + } + } + + HStack { + Text("Version") + .font(.nightLabel(15)) + .foregroundColor(.nightPrimary) + Spacer() + Text(appVersion) + .font(.nightMono(13)) + .foregroundColor(.nightSecondary) + } + } + .listRowBackground(Color.nightSurface) + + // Account-Aktionen + Section("account") { + Button { + appState.signOut() + dismiss() + } label: { + Text("abmelden") + .font(.nightLabel(15)) + .foregroundColor(.nightPrimary) + } + + Button(role: .destructive) { + showDeleteConfirm = true + } label: { + Text("account löschen") + .font(.nightLabel(15)) + .foregroundColor(.nightRed) + } + } + .listRowBackground(Color.nightSurface) + } + .scrollContentBackground(.hidden) + .listStyle(.insetGrouped) + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text("einstellungen") + .font(.nightTitle(17)) + .foregroundColor(.nightPrimary) + } + ToolbarItem(placement: .navigationBarLeading) { + Button("Fertig") { dismiss() } + .foregroundColor(.nightSecondary) + } + } + .sheet(isPresented: $showLegal) { LegalView() } + } + .preferredColorScheme(.dark) + // Schritt 1: Erklärung + .confirmationDialog( + "Account wirklich löschen?", + isPresented: $showDeleteConfirm, + titleVisibility: .visible + ) { + Button("Ja, Account löschen", role: .destructive) { + showDeleteFinal = true + } + } message: { + Text("Alle deine Posts, Reaktionen und Daten werden sofort und dauerhaft gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden.") + } + // Schritt 2: Passwort bestätigen + .sheet(isPresented: $showDeleteFinal) { + DeleteAccountSheet( + password: $deletePassword, + isDeleting: isDeleting, + error: deleteError, + onDelete: { Task { await deleteAccount() } } + ) + } + .onAppear { checkNotificationStatus() } + } + + var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + } + + func checkNotificationStatus() { + Task { + let settings = await UNUserNotificationCenter.current().notificationSettings() + notificationsEnabled = settings.authorizationStatus == .authorized + } + } + + func deleteAccount() async { + isDeleting = true + deleteError = nil + defer { isDeleting = false } + do { + try await appState.deleteAccount() + showDeleteFinal = false + dismiss() + } catch { + deleteError = error.localizedDescription + } + } +} + +struct DeleteAccountSheet: View { + @Binding var password: String + let isDeleting: Bool + let error: String? + let onDelete: () -> Void + @Environment(\.dismiss) var dismiss + + var body: some View { + NavigationStack { + ZStack { + Color.nightBase.ignoresSafeArea() + VStack(spacing: 24) { + Image(systemName: "trash.circle.fill") + .font(.system(size: 52)) + .foregroundColor(.nightRed) + + VStack(spacing: 8) { + Text("Account löschen") + .font(.nightTitle(22)) + .foregroundColor(.nightPrimary) + Text("Diese Aktion löscht alle deine Daten dauerhaft. Kein Weg zurück.") + .font(.nightBody(15)) + .foregroundColor(.nightSecondary) + .multilineTextAlignment(.center) + } + + NightlyField(text: $password, placeholder: "passwort bestätigen", icon: "lock", isSecure: true) + .padding(.horizontal, 24) + + if let err = error { + Text(err).font(.nightLabel(13)).foregroundColor(.nightRed) + } + + Button { + onDelete() + } label: { + Group { + if isDeleting { ProgressView().tint(.white) } + else { Text("endgültig löschen").font(.nightLabel(16, weight: .semibold)).foregroundColor(.white) } + } + .frame(maxWidth: .infinity).frame(height: 50) + .background(Color.nightRed) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .disabled(password.isEmpty || isDeleting) + .padding(.horizontal, 24) + } + .padding(.top, 32) + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Abbrechen") { dismiss() }.foregroundColor(.nightSecondary) + } + } + } + .preferredColorScheme(.dark) + .presentationDetents([.medium]) + } +} diff --git a/ios/thoughts/thoughts.xcodeproj/project.pbxproj b/ios/thoughts/thoughts.xcodeproj/project.pbxproj new file mode 100644 index 0000000..4fa4f9f --- /dev/null +++ b/ios/thoughts/thoughts.xcodeproj/project.pbxproj @@ -0,0 +1,636 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 95C8F89B2F9AC1BB00CA5386 /* Supabase in Frameworks */ = {isa = PBXBuildFile; productRef = 95C8F89A2F9AC1BB00CA5386 /* Supabase */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 95576B582F98D4200029BE54 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 95576B3E2F98D41F0029BE54 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 95576B452F98D41F0029BE54; + remoteInfo = thoughts; + }; + 95576B622F98D4200029BE54 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 95576B3E2F98D41F0029BE54 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 95576B452F98D41F0029BE54; + remoteInfo = thoughts; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 95576B462F98D41F0029BE54 /* thoughts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = thoughts.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 95576B572F98D4200029BE54 /* thoughtsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = thoughtsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 95576B612F98D4200029BE54 /* thoughtsUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = thoughtsUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 95576B692F98D4200029BE54 /* Exceptions for "thoughts" folder in "thoughts" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 95576B452F98D41F0029BE54 /* thoughts */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 95576B482F98D41F0029BE54 /* thoughts */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 95576B692F98D4200029BE54 /* Exceptions for "thoughts" folder in "thoughts" target */, + ); + path = thoughts; + sourceTree = ""; + }; + 95576B5A2F98D4200029BE54 /* thoughtsTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = thoughtsTests; + sourceTree = ""; + }; + 95576B642F98D4200029BE54 /* thoughtsUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = thoughtsUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 95576B432F98D41F0029BE54 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 95C8F89B2F9AC1BB00CA5386 /* Supabase in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 95576B542F98D4200029BE54 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 95576B5E2F98D4200029BE54 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 95576B3D2F98D41F0029BE54 = { + isa = PBXGroup; + children = ( + 95576B482F98D41F0029BE54 /* thoughts */, + 95576B5A2F98D4200029BE54 /* thoughtsTests */, + 95576B642F98D4200029BE54 /* thoughtsUITests */, + 95C8F8992F9AC1BB00CA5386 /* Frameworks */, + 95576B472F98D41F0029BE54 /* Products */, + ); + sourceTree = ""; + }; + 95576B472F98D41F0029BE54 /* Products */ = { + isa = PBXGroup; + children = ( + 95576B462F98D41F0029BE54 /* thoughts.app */, + 95576B572F98D4200029BE54 /* thoughtsTests.xctest */, + 95576B612F98D4200029BE54 /* thoughtsUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 95C8F8992F9AC1BB00CA5386 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 95576B452F98D41F0029BE54 /* thoughts */ = { + isa = PBXNativeTarget; + buildConfigurationList = 95576B6A2F98D4200029BE54 /* Build configuration list for PBXNativeTarget "thoughts" */; + buildPhases = ( + 95576B422F98D41F0029BE54 /* Sources */, + 95576B432F98D41F0029BE54 /* Frameworks */, + 95576B442F98D41F0029BE54 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 95576B482F98D41F0029BE54 /* thoughts */, + ); + name = thoughts; + packageProductDependencies = ( + 95C8F89A2F9AC1BB00CA5386 /* Supabase */, + ); + productName = thoughts; + productReference = 95576B462F98D41F0029BE54 /* thoughts.app */; + productType = "com.apple.product-type.application"; + }; + 95576B562F98D4200029BE54 /* thoughtsTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 95576B6F2F98D4200029BE54 /* Build configuration list for PBXNativeTarget "thoughtsTests" */; + buildPhases = ( + 95576B532F98D4200029BE54 /* Sources */, + 95576B542F98D4200029BE54 /* Frameworks */, + 95576B552F98D4200029BE54 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 95576B592F98D4200029BE54 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 95576B5A2F98D4200029BE54 /* thoughtsTests */, + ); + name = thoughtsTests; + packageProductDependencies = ( + ); + productName = thoughtsTests; + productReference = 95576B572F98D4200029BE54 /* thoughtsTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 95576B602F98D4200029BE54 /* thoughtsUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 95576B722F98D4200029BE54 /* Build configuration list for PBXNativeTarget "thoughtsUITests" */; + buildPhases = ( + 95576B5D2F98D4200029BE54 /* Sources */, + 95576B5E2F98D4200029BE54 /* Frameworks */, + 95576B5F2F98D4200029BE54 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 95576B632F98D4200029BE54 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 95576B642F98D4200029BE54 /* thoughtsUITests */, + ); + name = thoughtsUITests; + packageProductDependencies = ( + ); + productName = thoughtsUITests; + productReference = 95576B612F98D4200029BE54 /* thoughtsUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 95576B3E2F98D41F0029BE54 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2640; + LastUpgradeCheck = 2640; + TargetAttributes = { + 95576B452F98D41F0029BE54 = { + CreatedOnToolsVersion = 26.4.1; + }; + 95576B562F98D4200029BE54 = { + CreatedOnToolsVersion = 26.4.1; + TestTargetID = 95576B452F98D41F0029BE54; + }; + 95576B602F98D4200029BE54 = { + CreatedOnToolsVersion = 26.4.1; + TestTargetID = 95576B452F98D41F0029BE54; + }; + }; + }; + buildConfigurationList = 95576B412F98D41F0029BE54 /* Build configuration list for PBXProject "thoughts" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 95576B3D2F98D41F0029BE54; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 95C8F8962F9ABFD100CA5386 /* XCRemoteSwiftPackageReference "supabase-swift" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 95576B472F98D41F0029BE54 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 95576B452F98D41F0029BE54 /* thoughts */, + 95576B562F98D4200029BE54 /* thoughtsTests */, + 95576B602F98D4200029BE54 /* thoughtsUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 95576B442F98D41F0029BE54 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 95576B552F98D4200029BE54 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 95576B5F2F98D4200029BE54 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 95576B422F98D41F0029BE54 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 95576B532F98D4200029BE54 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 95576B5D2F98D4200029BE54 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 95576B592F98D4200029BE54 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 95576B452F98D41F0029BE54 /* thoughts */; + targetProxy = 95576B582F98D4200029BE54 /* PBXContainerItemProxy */; + }; + 95576B632F98D4200029BE54 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 95576B452F98D41F0029BE54 /* thoughts */; + targetProxy = 95576B622F98D4200029BE54 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 95576B6B2F98D4200029BE54 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = thoughts/thoughts.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8B9UP2YV66; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = thoughts/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dk0.dev.thoughts; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 95576B6C2F98D4200029BE54 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = thoughts/thoughts.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8B9UP2YV66; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = thoughts/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dk0.dev.thoughts; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 95576B6D2F98D4200029BE54 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 8B9UP2YV66; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 95576B6E2F98D4200029BE54 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 8B9UP2YV66; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 95576B702F98D4200029BE54 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8B9UP2YV66; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dk0.dev.thoughtsTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/thoughts.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/thoughts"; + }; + name = Debug; + }; + 95576B712F98D4200029BE54 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8B9UP2YV66; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dk0.dev.thoughtsTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/thoughts.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/thoughts"; + }; + name = Release; + }; + 95576B732F98D4200029BE54 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8B9UP2YV66; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dk0.dev.thoughtsUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = thoughts; + }; + name = Debug; + }; + 95576B742F98D4200029BE54 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8B9UP2YV66; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dk0.dev.thoughtsUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = thoughts; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 95576B412F98D41F0029BE54 /* Build configuration list for PBXProject "thoughts" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 95576B6D2F98D4200029BE54 /* Debug */, + 95576B6E2F98D4200029BE54 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 95576B6A2F98D4200029BE54 /* Build configuration list for PBXNativeTarget "thoughts" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 95576B6B2F98D4200029BE54 /* Debug */, + 95576B6C2F98D4200029BE54 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 95576B6F2F98D4200029BE54 /* Build configuration list for PBXNativeTarget "thoughtsTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 95576B702F98D4200029BE54 /* Debug */, + 95576B712F98D4200029BE54 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 95576B722F98D4200029BE54 /* Build configuration list for PBXNativeTarget "thoughtsUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 95576B732F98D4200029BE54 /* Debug */, + 95576B742F98D4200029BE54 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 95C8F8962F9ABFD100CA5386 /* XCRemoteSwiftPackageReference "supabase-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/supabase/supabase-swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.5.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 95C8F89A2F9AC1BB00CA5386 /* Supabase */ = { + isa = XCSwiftPackageProductDependency; + package = 95C8F8962F9ABFD100CA5386 /* XCRemoteSwiftPackageReference "supabase-swift" */; + productName = Supabase; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 95576B3E2F98D41F0029BE54 /* Project object */; +} diff --git a/ios/thoughts/thoughts.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/thoughts/thoughts.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/thoughts/thoughts.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/thoughts/thoughts.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/thoughts/thoughts.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/ios/thoughts/thoughts.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/ios/thoughts/thoughts.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/thoughts/thoughts.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..451b43a --- /dev/null +++ b/ios/thoughts/thoughts.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,69 @@ +{ + "originHash" : "5f3436049b395fcbc71828c07d82e81a46af698ebe0b146cdc52345a5a60558d", + "pins" : [ + { + "identity" : "supabase-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/supabase/supabase-swift", + "state" : { + "revision" : "06ae7b34ec21406cbd3e643bee7a8a54206fa8f5", + "version" : "2.44.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", + "version" : "4.5.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "dfd70507def84cb5fb821278448a262c6ff2bbad", + "version" : "1.9.0" + } + } + ], + "version" : 3 +} diff --git a/ios/thoughts/thoughts/AppState.swift b/ios/thoughts/thoughts/AppState.swift new file mode 100644 index 0000000..9c41528 --- /dev/null +++ b/ios/thoughts/thoughts/AppState.swift @@ -0,0 +1,135 @@ +import Combine +import SwiftUI +import Supabase + +@MainActor +class AppState: ObservableObject { + @Published var isAuthenticated = false + @Published var currentUser: User? + @Published var windowState: WindowState = .closed + + private var windowTimer: Timer? + + enum WindowState { case closed, open, posted, missed } + + init() { + Task { await checkSession() } + startWindowTimer() + observeAuthChanges() + } + + // MARK: - Auth + + func checkSession() async { + do { + let session = try await supabase.auth.session + isAuthenticated = true + await loadProfile(userId: session.user.id) + } catch { + #if DEBUG + // Auto-Login mit Dev-Account in Debug-Builds + do { + try await signIn(email: DevCredentials.email, password: DevCredentials.password) + print("[DEBUG] Auto-Login erfolgreich") + return + } catch { + print("[DEBUG] Auto-Login fehlgeschlagen: \(error.localizedDescription)") + } + #endif + isAuthenticated = false + } + } + + func signIn(email: String, password: String) async throws { + try await supabase.signIn(email: email, password: password) + let session = try await supabase.auth.session + isAuthenticated = true + await loadProfile(userId: session.user.id) + } + + func signIn(username: String, password: String) async throws { + try await supabase.signIn(username: username, password: password) + let session = try await supabase.auth.session + isAuthenticated = true + await loadProfile(userId: session.user.id) + } + + func signUp(email: String, password: String, username: String, displayName: String) async throws { + try await supabase.signUp(email: email, password: password, username: username, displayName: displayName) + let session = try await supabase.auth.session + isAuthenticated = true + await loadProfile(userId: session.user.id) + } + + func signOut() { + Task { + try? await supabase.auth.signOut() + } + isAuthenticated = false + currentUser = nil + } + + func deleteAccount() async throws { + try await supabase.deleteAccount() + isAuthenticated = false + currentUser = nil + } + + private func loadProfile(userId: UUID) async { + guard let profile = try? await supabase.getMyProfile() else { return } + currentUser = User( + id: profile.id.uuidString, + username: profile.username, + displayName: profile.displayName, + bio: profile.bio, + avatarURL: profile.avatarUrl.flatMap(URL.init), + followerCount: 0, + followingCount: 0, + postCount: 0, + isFollowing: false + ) + } + + private func observeAuthChanges() { + Task { + for await (event, session) in await supabase.auth.authStateChanges { + switch event { + case .signedIn: + if let session { + isAuthenticated = true + await loadProfile(userId: session.user.id) + } + case .signedOut, .userDeleted: + isAuthenticated = false + currentUser = nil + default: + break + } + } + } + } + + // MARK: - Window State + + func updateWindowState() { + let hour = Calendar.current.component(.hour, from: Date()) + guard hour >= 2 && hour < 5 else { windowState = .closed; return } + + let hasPosted = UserDefaults.standard.object(forKey: "lastPostDate") + .flatMap { $0 as? Date } + .map { Calendar.current.isDateInToday($0) } ?? false + windowState = hasPosted ? .posted : .open + } + + func markAsPosted() { + UserDefaults.standard.set(Date(), forKey: "lastPostDate") + updateWindowState() + } + + private func startWindowTimer() { + updateWindowState() + windowTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in self?.updateWindowState() } + } + } +} diff --git a/ios/thoughts/thoughts/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/thoughts/thoughts/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ios/thoughts/thoughts/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/thoughts/thoughts/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/thoughts/thoughts/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/ios/thoughts/thoughts/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/thoughts/thoughts/Assets.xcassets/Contents.json b/ios/thoughts/thoughts/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/thoughts/thoughts/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/thoughts/thoughts/Extensions/Colors.swift b/ios/thoughts/thoughts/Extensions/Colors.swift new file mode 100644 index 0000000..c16e459 --- /dev/null +++ b/ios/thoughts/thoughts/Extensions/Colors.swift @@ -0,0 +1,79 @@ +import SwiftUI + +// MARK: - Design Tokens + +extension Color { + // Backgrounds — kein reines Schwarz, sondern Mitternachtsblau + static let nightBase = Color(hex: "080810") // Haupt-Hintergrund + static let nightSurface = Color(hex: "0E0E1C") // Karten, Sheets + static let nightRaised = Color(hex: "151528") // Elevated surfaces + static let nightBorder = Color(white: 1, opacity: 0.06) + + // Text + static let nightPrimary = Color(hex: "EEEEF8") + static let nightSecondary = Color(hex: "64647A") + static let nightTertiary = Color(hex: "3A3A52") + + // Akzente + static let nightPurple = Color(hex: "7B4FE8") + static let nightPurpleSoft = Color(hex: "9B77F0") + static let nightGreen = Color(hex: "34D399") + static let nightRed = Color(hex: "F27474") + + // Hex initializer + init(hex: String) { + let h = hex.trimmingCharacters(in: .alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: h).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch h.count { + case 6: (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default:(a, r, g, b) = (255, 255, 255, 255) + } + self.init(.sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255) + } +} + +// MARK: - Mood (passt hier semantisch besser rein als in Post.swift) + +extension Mood { + var color: Color { + switch self { + case .still: return Color(hex: "4A9EFF") + case .unruhig: return Color(hex: "FF8C42") + case .melancholisch: return Color(hex: "A855F7") + case .aufgedreht: return Color(hex: "10D08A") + } + } + var label: String { rawValue } + var emoji: String { + switch self { + case .still: return "◌" + case .unruhig: return "◎" + case .melancholisch: return "◑" + case .aufgedreht: return "◉" + } + } +} + +// MARK: - Typography helpers + +extension Font { + static func nightTitle(_ size: CGFloat) -> Font { + .system(size: size, weight: .bold, design: .rounded) + } + static func nightBody(_ size: CGFloat) -> Font { + .system(size: size, weight: .regular) + } + static func nightMono(_ size: CGFloat) -> Font { + .system(size: size, design: .monospaced) + } + static func nightLabel(_ size: CGFloat, weight: Font.Weight = .medium) -> Font { + .system(size: size, weight: weight) + } +} diff --git a/ios/thoughts/thoughts/Extensions/Haptics.swift b/ios/thoughts/thoughts/Extensions/Haptics.swift new file mode 100644 index 0000000..e107790 --- /dev/null +++ b/ios/thoughts/thoughts/Extensions/Haptics.swift @@ -0,0 +1,30 @@ +import UIKit + +/// Zentrales Haptic-Feedback für die gesamte App. +enum Haptics { + private static let lightImpact = UIImpactFeedbackGenerator(style: .light) + private static let mediumImpact = UIImpactFeedbackGenerator(style: .medium) + private static let selection = UISelectionFeedbackGenerator() + private static let notification = UINotificationFeedbackGenerator() + + /// Leichtes Feedback — Resonance-Button, Follow + static func light() { lightImpact.impactOccurred() } + + /// Mittleres Feedback — Abmelden, wichtige Aktionen + static func medium() { mediumImpact.impactOccurred() } + + /// Ganz sanftes Feedback — Mood-Auswahl + static func soft() { lightImpact.impactOccurred(intensity: 0.45) } + + /// Selection-Feedback — Tab-Wechsel, Toggles + static func select() { selection.selectionChanged() } + + /// Erfolg — Post gesendet, Follow erfolgreich + static func success() { notification.notificationOccurred(.success) } + + /// Warnung — Account löschen + static func warning() { notification.notificationOccurred(.warning) } + + /// Fehler — Post fehlgeschlagen + static func error() { notification.notificationOccurred(.error) } +} diff --git a/ios/thoughts/thoughts/Extensions/ShimmerModifier.swift b/ios/thoughts/thoughts/Extensions/ShimmerModifier.swift new file mode 100644 index 0000000..a8a1539 --- /dev/null +++ b/ios/thoughts/thoughts/Extensions/ShimmerModifier.swift @@ -0,0 +1,138 @@ +import SwiftUI + +// MARK: - Shimmer Modifier + +/// Gleitender Lichteffekt über Skeleton-Elemente. +struct ShimmerModifier: ViewModifier { + @State private var phase: CGFloat = -1 + + func body(content: Content) -> some View { + content + .overlay( + GeometryReader { geo in + LinearGradient( + colors: [ + .clear, + Color.white.opacity(0.04), + Color.white.opacity(0.08), + Color.white.opacity(0.04), + .clear + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: geo.size.width * 1.5) + .offset(x: phase * geo.size.width * 1.5) + } + .clipped() + ) + .onAppear { + withAnimation(.linear(duration: 1.6).repeatForever(autoreverses: false)) { + phase = 1 + } + } + } +} + +extension View { + func shimmer() -> some View { + modifier(ShimmerModifier()) + } +} + +// MARK: - Skeleton Post Row + +/// Platzhalter-Zeile die aussieht wie ein echter Post. +struct SkeletonPostRow: View { + var body: some View { + HStack(alignment: .top, spacing: 0) { + // Mood-Accent-Bar + RoundedRectangle(cornerRadius: 1) + .fill(Color.nightTertiary.opacity(0.2)) + .frame(width: 2) + .padding(.vertical, 18) + + VStack(alignment: .leading, spacing: 11) { + // Avatar + Name + Zeitstempel + HStack(spacing: 9) { + Circle() + .fill(Color.nightRaised) + .frame(width: 32, height: 32) + RoundedRectangle(cornerRadius: 4) + .fill(Color.nightRaised) + .frame(width: 90, height: 13) + Spacer() + RoundedRectangle(cornerRadius: 4) + .fill(Color.nightRaised) + .frame(width: 36, height: 11) + } + + // Content-Zeilen + VStack(alignment: .leading, spacing: 6) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.nightRaised) + .frame(height: 13) + RoundedRectangle(cornerRadius: 4) + .fill(Color.nightRaised) + .frame(width: 180, height: 13) + } + + // Resonance-Button Platzhalter + RoundedRectangle(cornerRadius: 8) + .fill(Color.nightRaised) + .frame(width: 90, height: 24) + } + .padding(.leading, 14) + .padding(.trailing, 16) + .padding(.vertical, 16) + } + .shimmer() + } +} + +// MARK: - Skeleton Profile Header + +/// Platzhalter für den Profilkopf beim Laden. +struct SkeletonProfileHeader: View { + var body: some View { + VStack(spacing: 16) { + // Avatar + Circle() + .fill(Color.nightRaised) + .frame(width: 76, height: 76) + + // Name + Username + VStack(spacing: 8) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.nightRaised) + .frame(width: 120, height: 18) + RoundedRectangle(cornerRadius: 4) + .fill(Color.nightRaised) + .frame(width: 80, height: 13) + } + + // Stats + HStack(spacing: 36) { + ForEach(0..<3, id: \.self) { _ in + VStack(spacing: 4) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.nightRaised) + .frame(width: 28, height: 16) + RoundedRectangle(cornerRadius: 4) + .fill(Color.nightRaised) + .frame(width: 48, height: 11) + } + } + } + + // Action Button + RoundedRectangle(cornerRadius: 10) + .fill(Color.nightRaised) + .frame(height: 40) + .padding(.horizontal, 48) + } + .padding(.top, 28) + .padding(.bottom, 20) + .shimmer() + } +} diff --git a/ios/thoughts/thoughts/Info.plist b/ios/thoughts/thoughts/Info.plist new file mode 100644 index 0000000..74a91d6 --- /dev/null +++ b/ios/thoughts/thoughts/Info.plist @@ -0,0 +1,10 @@ + + + + + SUPABASE_URL + $(SUPABASE_URL) + SUPABASE_ANON_KEY + $(SUPABASE_ANON_KEY) + + diff --git a/ios/thoughts/thoughts/Models/Post.swift b/ios/thoughts/thoughts/Models/Post.swift new file mode 100644 index 0000000..8c6012f --- /dev/null +++ b/ios/thoughts/thoughts/Models/Post.swift @@ -0,0 +1,107 @@ +import Foundation + +// MARK: - Mood + +enum Mood: String, Codable, CaseIterable { + case still = "still" + case unruhig = "unruhig" + case melancholisch = "melancholisch" + case aufgedreht = "aufgedreht" + // color, label, emoji → Colors.swift extension +} + +// MARK: - Post + +struct Post: Identifiable, Codable { + let id: String + let author: User + let content: String + let mood: Mood? + let createdAt: Date + var resonanceCount: Int // "Hat mich getroffen" + var hasResonated: Bool // Current user's reaction + var commentCount: Int + let isAnonymous: Bool + let nightOf: Date + + var isExpired: Bool { + Date().timeIntervalSince(createdAt) > 14 * 3_600 + } + + // Is this post in the "Gerade Jetzt" window (< 10 min old) + var isRightNow: Bool { + Date().timeIntervalSince(createdAt) < 10 * 60 + } + + var formattedTime: String { + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f.string(from: createdAt) + } + + var timeAgo: String { + let diff = Date().timeIntervalSince(createdAt) + if diff < 60 { return "gerade eben" } + if diff < 3_600 { return "\(Int(diff / 60))m" } + return "\(Int(diff / 3_600))h" + } + + static let previews: [Post] = [ + Post( + id: "1", + author: .preview, + content: "warum denk ich um 3 uhr morgens noch an das was ich 2019 gesagt hab", + mood: .melancholisch, + createdAt: Date().addingTimeInterval(-180), + resonanceCount: 12, + hasResonated: false, + commentCount: 3, + isAnonymous: false, + nightOf: Date() + ), + Post( + id: "2", + author: User( + id: "2", username: "insomniac_", displayName: "can't sleep", + bio: nil, avatarURL: nil, + followerCount: 88, followingCount: 44, postCount: 12, isFollowing: true + ), + content: "das licht vom handy macht alles schlimmer aber ich leg es trotzdem nicht weg", + mood: .unruhig, + createdAt: Date().addingTimeInterval(-900), + resonanceCount: 8, + hasResonated: true, + commentCount: 1, + isAnonymous: false, + nightOf: Date() + ), + Post( + id: "3", + author: .preview, + content: "ich warte irgendwie immer noch auf eine nachricht von dir obwohl ich weiß dass sie nicht kommt", + mood: .melancholisch, + createdAt: Date().addingTimeInterval(-300), + resonanceCount: 31, + hasResonated: true, + commentCount: 7, + isAnonymous: true, + nightOf: Date() + ), + Post( + id: "4", + author: User( + id: "4", username: "felix.nacht", displayName: "Felix", + bio: nil, avatarURL: nil, + followerCount: 33, followingCount: 20, postCount: 8, isFollowing: false + ), + content: "hab gerade realisiert dass ich seit 4 stunden auf tiktok bin und morgen um 7 aufstehen muss", + mood: .aufgedreht, + createdAt: Date().addingTimeInterval(-60), + resonanceCount: 5, + hasResonated: false, + commentCount: 0, + isAnonymous: false, + nightOf: Date() + ) + ] +} diff --git a/ios/thoughts/thoughts/Models/User.swift b/ios/thoughts/thoughts/Models/User.swift new file mode 100644 index 0000000..7ada9b0 --- /dev/null +++ b/ios/thoughts/thoughts/Models/User.swift @@ -0,0 +1,25 @@ +import Foundation + +struct User: Identifiable, Codable, Equatable { + let id: String + let username: String + var displayName: String + var bio: String? + var avatarURL: URL? + var followerCount: Int + var followingCount: Int + var postCount: Int + var isFollowing: Bool + + static let preview = User( + id: "preview", + username: "nightowl", + displayName: "Night Owl", + bio: "3 Uhr ist meine goldene Stunde", + avatarURL: nil, + followerCount: 142, + followingCount: 89, + postCount: 37, + isFollowing: false + ) +} diff --git a/ios/thoughts/thoughts/NightlyApp.swift b/ios/thoughts/thoughts/NightlyApp.swift new file mode 100644 index 0000000..10b21a4 --- /dev/null +++ b/ios/thoughts/thoughts/NightlyApp.swift @@ -0,0 +1,67 @@ +import SwiftUI + +@main +struct NightlyApp: App { + @StateObject private var appState = AppState() + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + WindowGroup { + RootView() + .environmentObject(appState) + .preferredColorScheme(.dark) + } + } +} + +// MARK: - App Delegate + +class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + UNUserNotificationCenter.current().delegate = self + return true + } + + // ⚠️ Push Notifications: erfordert bezahlten Apple Developer Account ($99/Jahr) + // Ohne Developer-Account kann dieser Code nicht getestet werden (nur Simulator ohne Pushs) + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() + Task { try? await supabase.savePushToken(token) } + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + print("APNs Registrierung fehlgeschlagen:", error.localizedDescription) + // Häufige Ursache: kein bezahlter Developer Account + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound, .badge]) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + NotificationCenter.default.post(name: .nightlyPingReceived, object: nil) + completionHandler() + } +} + +extension Notification.Name { + static let nightlyPingReceived = Notification.Name("nightlyPingReceived") +} diff --git a/ios/thoughts/thoughts/Secrets.swift b/ios/thoughts/thoughts/Secrets.swift new file mode 100644 index 0000000..606f503 --- /dev/null +++ b/ios/thoughts/thoughts/Secrets.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Konfiguration aus dem Xcode Build-System (xcconfig / Info.plist). +/// +/// Setup: +/// 1. Datei `Config.xcconfig` im Projektverzeichnis anlegen (nicht committen!): +/// SUPABASE_URL = https://api.xxx.dk0.dev +/// SUPABASE_ANON_KEY = eyJhbGci... +/// +/// 2. In Xcode: Project → Info → Configurations → Debug & Release auf Config.xcconfig setzen +/// 3. In Info.plist eintragen: +/// SUPABASE_URL → $(SUPABASE_URL) +/// SUPABASE_ANON_KEY → $(SUPABASE_ANON_KEY) + +#if DEBUG +enum DevCredentials { + static let email = "dev@nightly.test" + static let password = "TestPassword123!" +} +#endif + +enum Config { + static let supabaseURL: URL = { + guard + let raw = Bundle.main.object(forInfoDictionaryKey: "SUPABASE_URL") as? String, + !raw.isEmpty, + let url = URL(string: raw) + else { + // Fallback für Entwicklung — ersetze mit deiner URL + return URL(string: "https://api.xxx.dk0.dev")! + } + return url + }() + + static let supabaseAnonKey: String = { + let key = Bundle.main.object(forInfoDictionaryKey: "SUPABASE_ANON_KEY") as? String ?? "" + if key.isEmpty { + print("⚠️ SUPABASE_ANON_KEY nicht gesetzt — Config.xcconfig prüfen") + } + return key + }() +} diff --git a/ios/thoughts/thoughts/Services/RealtimeService.swift b/ios/thoughts/thoughts/Services/RealtimeService.swift new file mode 100644 index 0000000..f11c370 --- /dev/null +++ b/ios/thoughts/thoughts/Services/RealtimeService.swift @@ -0,0 +1,87 @@ +import Combine +import Foundation +import Supabase + +/// Verwaltet die Echtzeit-Verbindung für "Gerade Jetzt". +/// Neue Posts erscheinen sofort ohne Polling. +@MainActor +class RealtimeService: ObservableObject { + @Published var newPostsCount = 0 + + private var channel: RealtimeChannelV2? + private var onNewPost: ((Post) -> Void)? + + func startListening(onNewPost: @escaping (Post) -> Void) async { + self.onNewPost = onNewPost + guard channel == nil else { return } + + let ch = await supabase.channel("public:posts") + + // Neue Posts in Echtzeit empfangen + let stream = await ch.postgresChange( + InsertAction.self, + schema: "public", + table: "posts" + ) + + await ch.subscribe() + self.channel = ch + + // Stream im Hintergrund konsumieren + Task { [weak self] in + for await action in stream { + await self?.handleInsert(action) + } + } + } + + func stopListening() async { + if let ch = channel { + await supabase.removeChannel(ch) + channel = nil + } + } + + private func handleInsert(_ action: InsertAction) { + // Den neuen Post aus dem Record dekodieren + guard + let id = action.record["id"]?.stringValue, + let content = action.record["content"]?.stringValue, + let createdAt = action.record["created_at"]?.stringValue + .flatMap({ ISO8601DateFormatter().date(from: $0) }), + let userId = action.record["user_id"]?.stringValue, + let isAnon = action.record["is_anonymous"]?.boolValue + else { return } + + let moodString = action.record["mood"]?.stringValue + let mood = moodString.flatMap(Mood.init(rawValue:)) + + let post = Post( + id: id, + author: User.anonymousPlaceholder, // Profil wird lazily nachgeladen + content: content, + mood: mood, + createdAt: createdAt, + resonanceCount: 0, + hasResonated: false, + commentCount: 0, + isAnonymous: isAnon, + nightOf: createdAt + ) + + newPostsCount += 1 + onNewPost?(post) + } +} + +// MARK: - User placeholder für Realtime (Profil wird nachgeladen) + +extension User { + static let anonymousPlaceholder = User( + id: "anonymous", + username: "anonym", + displayName: "anonym", + bio: nil, avatarURL: nil, + followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false + ) +} diff --git a/ios/thoughts/thoughts/Services/SupabaseService.swift b/ios/thoughts/thoughts/Services/SupabaseService.swift new file mode 100644 index 0000000..ddb54ec --- /dev/null +++ b/ios/thoughts/thoughts/Services/SupabaseService.swift @@ -0,0 +1,390 @@ +import Foundation +import Supabase + +// MARK: - Supabase Client (Singleton) + +let supabase = SupabaseClient( + supabaseURL: Config.supabaseURL, + supabaseKey: Config.supabaseAnonKey, + options: SupabaseClientOptions( + db: .init( + encoder: { + let e = JSONEncoder() + e.keyEncodingStrategy = .convertToSnakeCase + e.dateEncodingStrategy = .iso8601 + return e + }(), + decoder: { + let d = JSONDecoder() + d.keyDecodingStrategy = .convertFromSnakeCase + d.dateDecodingStrategy = .iso8601 + return d + }() + ) + ) +) + +// MARK: - Auth + +extension SupabaseClient { + + func signUp(email: String, password: String, username: String, displayName: String) async throws { + try await self.auth.signUp( + email: email, + password: password, + data: [ + "username": .string(username.lowercased()), + "display_name": .string(displayName) + ] + ) + } + + func signIn(email: String, password: String) async throws { + try await self.auth.signIn(email: email, password: password) + } + + /// Login mit Username: holt zuerst die E-Mail, dann normaler Sign-In + func signIn(username: String, password: String) async throws { + let email: String? = try await self + .rpc("get_email_by_username", params: ["p_username": username]) + .execute() + .value + guard let email else { throw AuthError.usernameNotFound } + try await self.auth.signIn(email: email, password: password) + } + + func signOut() async throws { + try await self.auth.signOut() + } + + /// Account vollständig löschen (DSGVO — löscht alles über DB-Funktion) + func deleteAccount() async throws { + try await self.rpc("delete_my_account").execute() + } + + var currentUserId: UUID? { + get async { + try? await self.auth.session.user.id + } + } +} + +// MARK: - Profil + +extension SupabaseClient { + + func getMyProfile() async throws -> Profile { + guard let uid = await currentUserId else { throw AuthError.notAuthenticated } + return try await self + .from("profiles") + .select() + .eq("id", value: uid) + .single() + .execute() + .value + } + + func getProfile(userId: UUID) async throws -> Profile { + try await self + .from("profiles") + .select() + .eq("id", value: userId) + .single() + .execute() + .value + } + + func updateProfile(displayName: String? = nil, bio: String? = nil) async throws { + guard let uid = await currentUserId else { throw AuthError.notAuthenticated } + var update: [String: String] = [:] + if let n = displayName { update["display_name"] = n } + if let b = bio { update["bio"] = b } + guard !update.isEmpty else { return } + try await self.from("profiles").update(update).eq("id", value: uid).execute() + } + + func savePushToken(_ token: String) async throws { + guard let uid = await currentUserId else { return } + try await self.from("profiles") + .update(["push_token": token]) + .eq("id", value: uid) + .execute() + } + + func removePushToken() async throws { + guard let uid = await currentUserId else { return } + try await self.from("profiles") + .update(["push_token": nil as String?]) + .eq("id", value: uid) + .execute() + } +} + +// MARK: - Posts + +extension SupabaseClient { + + /// Feed: Posts der letzten 14h von gefollowten Usern + eigene + func getFeed() async throws -> [Post] { + guard let uid = await currentUserId else { return [] } + + // Erst die gefolgten User-IDs holen + // Supabase gibt Objekte zurück [{following_id:"uuid"}], kein [String] + struct FollowRow: Decodable { let followingId: String } + let followRows: [FollowRow] = try await self + .from("follows") + .select("following_id") + .eq("follower_id", value: uid) + .execute() + .value + + let allIds = followRows.map(\.followingId) + [uid.uuidString] + + let rows: [FeedPostRow] = try await self + .from("feed_posts") + .select() + .in("author_id", values: allIds) + .order("created_at", ascending: false) + .limit(150) + .execute() + .value + + // Eigene Resonances holen (RLS filtert, SDK gibt nur eigene zurück) + let myResonances: [ResonanceRow] = (try? await self + .from("resonances") + .select("post_id") + .eq("user_id", value: uid) + .execute() + .value) ?? [] + + let mySet = Set(myResonances.map(\.postId)) + + return rows.map { row in row.toPost(hasResonated: mySet.contains(row.id)) } + } + + /// Persönliches Tagebuch: alle eigenen Posts, auch gelöschte (soft) + func getDiary() async throws -> [Post] { + guard let uid = await currentUserId else { return [] } + let rows: [FeedPostRow] = try await self + .from("posts") + .select("id, content, mood, is_anonymous, created_at, resonance_count:resonances(count)") + .eq("user_id", value: uid) + .is("deleted_at", value: nil) + .order("created_at", ascending: false) + .limit(365) + .execute() + .value + return rows.map { $0.toPost(hasResonated: false) } + } + + func getUserPosts(userId: UUID) async throws -> [Post] { + let rows: [FeedPostRow] = try await self + .from("feed_posts") + .select() + .eq("author_id", value: userId) + .order("created_at", ascending: false) + .limit(50) + .execute() + .value + return rows.map { $0.toPost(hasResonated: false) } + } + + func createPost(content: String, mood: Mood, isAnonymous: Bool) async throws { + guard let uid = await currentUserId else { throw AuthError.notAuthenticated } + struct Params: Encodable { + let userId: String + let content: String + let mood: String + let isAnonymous: Bool + } + try await self.from("posts").insert( + Params(userId: uid.uuidString, content: content, mood: mood.rawValue, isAnonymous: isAnonymous) + ).execute() + } + + func softDeletePost(id: String) async throws { + try await self.from("posts") + .update(["deleted_at": ISO8601DateFormatter().string(from: Date())]) + .eq("id", value: id) + .execute() + } +} + +// MARK: - Resonances + +extension SupabaseClient { + + func toggleResonance(postId: String, currentlyActive: Bool) async throws { + guard let uid = await currentUserId else { throw AuthError.notAuthenticated } + if currentlyActive { + try await self.from("resonances") + .delete() + .eq("post_id", value: postId) + .eq("user_id", value: uid) + .execute() + } else { + try await self.from("resonances") + .insert(["post_id": postId, "user_id": uid.uuidString]) + .execute() + } + } +} + +// MARK: - Follows + +extension SupabaseClient { + + func follow(userId: UUID) async throws { + guard let uid = await currentUserId else { throw AuthError.notAuthenticated } + try await self.from("follows") + .insert(["follower_id": uid.uuidString, "following_id": userId.uuidString]) + .execute() + } + + func unfollow(userId: UUID) async throws { + guard let uid = await currentUserId else { throw AuthError.notAuthenticated } + try await self.from("follows") + .delete() + .eq("follower_id", value: uid) + .eq("following_id", value: userId) + .execute() + } + + func getStreak(userId: UUID) async throws -> Int { + // Nächte mit Posts — berechnet in SQL + let rows: [[String: Int]] = (try? await self + .rpc("get_streak", params: ["p_user_id": userId.uuidString]) + .execute() + .value) ?? [] + return rows.first?["streak"] ?? 0 + } +} + +// MARK: - Reports + +extension SupabaseClient { + + func reportPost(postId: String, reason: String, details: String?) async throws { + guard let uid = await currentUserId else { throw AuthError.notAuthenticated } + struct Params: Encodable { + let postId: String + let reporterId: String + let reason: String + let details: String? + } + try await self.from("reports").insert( + Params(postId: postId, reporterId: uid.uuidString, reason: reason, details: details) + ).execute() + } +} + +// MARK: - Whispers + +extension SupabaseClient { + + func sendWhisper(toUserId: UUID, content: String, postId: String?) async throws { + guard let uid = await currentUserId else { throw AuthError.notAuthenticated } + struct Params: Encodable { + let fromUserId: String + let toUserId: String + let content: String + let postId: String? + } + try await self.from("whispers").insert( + Params(fromUserId: uid.uuidString, toUserId: toUserId.uuidString, content: content, postId: postId) + ).execute() + } + + func getMyWhispers() async throws -> [Whisper] { + guard let uid = await currentUserId else { return [] } + return try await self + .from("whispers") + .select("*, from_profile:profiles!from_user_id(username, display_name, avatar_url)") + .eq("to_user_id", value: uid) + .order("created_at", ascending: false) + .limit(50) + .execute() + .value + } + + func markWhisperRead(id: UUID) async throws { + try await self.from("whispers") + .update(["read_at": ISO8601DateFormatter().string(from: Date())]) + .eq("id", value: id) + .execute() + } +} + +// MARK: - Errors + +enum AuthError: LocalizedError { + case notAuthenticated + case usernameNotFound + + var errorDescription: String? { + switch self { + case .notAuthenticated: return "Nicht angemeldet" + case .usernameNotFound: return "Benutzername nicht gefunden" + } + } +} + +// MARK: - Row types (Supabase responses) + +struct FeedPostRow: Decodable { + let id: String + let content: String + let mood: String? + let isAnonymous: Bool + let createdAt: Date + let resonanceCount: Int + // Autor (nil bei anonymen Posts die nicht von mir sind) + let authorId: String? + let authorUsername: String? + let authorDisplayName: String? + let authorAvatarUrl: String? + + func toPost(hasResonated: Bool) -> Post { + let author: User? = authorId.map { + User( + id: $0, + username: authorUsername ?? "?", + displayName: authorDisplayName ?? "?", + bio: nil, avatarURL: authorAvatarUrl.flatMap(URL.init), + followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false + ) + } + return Post( + id: id, + author: author ?? User.anonymousPlaceholder, + content: content, + mood: mood.flatMap(Mood.init(rawValue:)), + createdAt: createdAt, + resonanceCount: resonanceCount, + hasResonated: hasResonated, + commentCount: 0, + isAnonymous: isAnonymous, + nightOf: createdAt + ) + } +} + +struct ResonanceRow: Decodable { let postId: String } +struct Profile: Decodable { + let id: UUID + let username: String + let displayName: String + let bio: String? + let avatarUrl: String? + let isPro: Bool + let isAdmin: Bool + let createdAt: Date +} + +struct Whisper: Identifiable, Decodable { + let id: UUID + let fromUserId: UUID + let content: String + let readAt: Date? + let createdAt: Date +} diff --git a/ios/thoughts/thoughts/ViewModels/FeedViewModel.swift b/ios/thoughts/thoughts/ViewModels/FeedViewModel.swift new file mode 100644 index 0000000..f7ff381 --- /dev/null +++ b/ios/thoughts/thoughts/ViewModels/FeedViewModel.swift @@ -0,0 +1,41 @@ +import Combine +import Foundation + +@MainActor +class FeedViewModel: ObservableObject { + @Published var posts: [Post] = [] + @Published var isLoading = false + + func load() async { + isLoading = true + defer { isLoading = false } + do { + posts = try await supabase.getFeed() + } catch { + #if DEBUG + posts = Post.previews + #endif + } + } + + func resonate(_ post: Post) async { + guard let idx = posts.firstIndex(where: { $0.id == post.id }) else { return } + let wasActive = posts[idx].hasResonated + + posts[idx].hasResonated = !wasActive + posts[idx].resonanceCount += wasActive ? -1 : 1 + + do { + try await supabase.toggleResonance(postId: post.id, currentlyActive: wasActive) + } catch { + posts[idx].hasResonated = wasActive + posts[idx].resonanceCount += wasActive ? 1 : -1 + } + } + + /// Neuen Post vom Realtime-Service in den Feed einfügen + func prepend(_ post: Post) { + guard !posts.contains(where: { $0.id == post.id }) else { return } + posts.insert(post, at: 0) + } +} diff --git a/ios/thoughts/thoughts/ViewModels/ProfileViewModel.swift b/ios/thoughts/thoughts/ViewModels/ProfileViewModel.swift new file mode 100644 index 0000000..28803b4 --- /dev/null +++ b/ios/thoughts/thoughts/ViewModels/ProfileViewModel.swift @@ -0,0 +1,34 @@ +import Combine +import Foundation + +@MainActor +class ProfileViewModel: ObservableObject { + @Published var posts: [Post] = [] + @Published var streak: Int = 0 + @Published var isLoading = false + + let userId: UUID + + init(userId: UUID) { + self.userId = userId + } + + convenience init(userIdString: String) { + self.init(userId: UUID(uuidString: userIdString) ?? UUID()) + } + + func load() async { + isLoading = true + defer { isLoading = false } + do { + async let postsTask = supabase.getUserPosts(userId: userId) + async let streakTask = supabase.getStreak(userId: userId) + (posts, streak) = try await (postsTask, streakTask) + } catch { + #if DEBUG + posts = Post.previews + streak = 4 + #endif + } + } +} diff --git a/ios/thoughts/thoughts/Views/ComposeView.swift b/ios/thoughts/thoughts/Views/ComposeView.swift new file mode 100644 index 0000000..d258c65 --- /dev/null +++ b/ios/thoughts/thoughts/Views/ComposeView.swift @@ -0,0 +1,325 @@ +import Combine +import SwiftUI + +struct ComposeView: View { + @EnvironmentObject var appState: AppState + @Environment(\.dismiss) var dismiss + + @State private var text = "" + @State private var selectedMood: Mood? = nil + @State private var isAnonymous = false + @State private var isPosting = false + @State private var errorMessage: String? + + private let maxChars = 280 + var remaining: Int { maxChars - text.count } + var canPost: Bool { !text.trimmingCharacters(in: .whitespaces).isEmpty && selectedMood != nil } + + // Background tint based on mood + var moodBackground: Color { + selectedMood?.color.opacity(0.06) ?? .clear + } + + var body: some View { + NavigationStack { + ZStack { + Color.nightBase.ignoresSafeArea() + moodBackground.ignoresSafeArea() + .animation(.easeInOut(duration: 0.5), value: selectedMood) + + VStack(spacing: 0) { + // Top meta bar + HStack { + Label(currentTime, systemImage: "moon.stars.fill") + .font(.nightMono(12)) + .foregroundColor(.nightPurple.opacity(0.7)) + .labelStyle(.titleAndIcon) + + Spacer() + + // Character count + Group { + if remaining <= 30 { + Text("\(remaining)") + .foregroundColor(remaining <= 10 ? .nightRed : .nightSecondary) + } + } + .font(.nightMono(13)) + .animation(.easeInOut, value: remaining) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + + Divider().background(Color.nightBorder) + + // Text field area + ScrollView { + HStack(alignment: .top, spacing: 12) { + // Left: Avatar + VStack(spacing: 0) { + if isAnonymous { + AnonymousAvatar(size: 38) + } else if let user = appState.currentUser { + AvatarView(user: user, size: 38) + } else { + Circle() + .fill(Color.nightRaised) + .frame(width: 38, height: 38) + } + // Connector line (visual polish) + Rectangle() + .fill(Color.nightBorder) + .frame(width: 1) + .frame(maxHeight: .infinity) + .padding(.top, 8) + } + .frame(width: 38) + + // Right: Content + VStack(alignment: .leading, spacing: 8) { + // Name + Text(isAnonymous ? "anonym" : (appState.currentUser?.displayName ?? "")) + .font(.nightLabel(15, weight: .semibold)) + .foregroundColor(isAnonymous ? .nightSecondary : .nightPrimary) + .italic(isAnonymous) + + // TextEditor with placeholder + ZStack(alignment: .topLeading) { + if text.isEmpty { + Text("Was geht dir gerade durch den Kopf?") + .font(.nightBody(17)) + .foregroundColor(.nightTertiary) + .allowsHitTesting(false) + .padding(.top, 8) + .padding(.leading, 5) + } + TextEditor(text: $text) + .scrollContentBackground(.hidden) + .background(.clear) + .foregroundColor(.nightPrimary) + .font(.nightBody(17)) + .lineSpacing(5) + .frame(minHeight: 160) + .onChange(of: text) { _, new in + if new.count > maxChars { + text = String(new.prefix(maxChars)) + } + } + } + + // Mood picker inline + MoodPickerRow(selected: $selectedMood) + .padding(.top, 4) + + Spacer().frame(height: 20) + } + } + .padding(.horizontal, 16) + .padding(.top, 18) + } + + Spacer() + + // Bottom bar: anonymous toggle + countdown + Divider().background(Color.nightBorder) + + HStack(spacing: 14) { + // Anonymous toggle + Button { + Haptics.select() + withAnimation(.spring(duration: 0.3, bounce: 0.4)) { + isAnonymous.toggle() + } + } label: { + HStack(spacing: 6) { + Image(systemName: isAnonymous ? "theatermasks.fill" : "theatermasks") + .font(.system(size: 15)) + Text(isAnonymous ? "anonym" : "anonym posten") + .font(.nightLabel(13)) + } + .foregroundColor(isAnonymous ? .nightPrimary : .nightSecondary) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(isAnonymous ? Color.nightRaised : .clear) + .clipShape(Capsule()) + .overlay( + Capsule() + .strokeBorder( + isAnonymous ? Color.nightBorder : .clear, + lineWidth: 1 + ) + ) + } + + Spacer() + + WindowCountdownView() + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .padding(.bottom, 8) + + if let err = errorMessage { + Text(err) + .font(.nightLabel(13)) + .foregroundColor(.nightRed) + .padding(.horizontal, 20) + .padding(.bottom, 8) + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Abbrechen") { dismiss() } + .foregroundColor(.nightSecondary) + .font(.nightBody(16)) + } + ToolbarItem(placement: .navigationBarTrailing) { + PostButton(canPost: canPost, isPosting: isPosting) { + Task { await submit() } + } + } + } + } + .preferredColorScheme(.dark) + } + + var currentTime: String { + let f = DateFormatter(); f.dateFormat = "HH:mm" + return f.string(from: Date()) + } + + func submit() async { + guard let mood = selectedMood else { return } + isPosting = true + errorMessage = nil + defer { isPosting = false } + do { + try await supabase.createPost( + content: text.trimmingCharacters(in: .whitespacesAndNewlines), + mood: mood, + isAnonymous: isAnonymous + ) + Haptics.success() + appState.markAsPosted() + dismiss() + } catch { + Haptics.error() + errorMessage = error.localizedDescription + } + } +} + +// MARK: - Mood Picker + +struct MoodPickerRow: View { + @Binding var selected: Mood? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("stimmung") + .font(.nightLabel(11, weight: .medium)) + .foregroundColor(.nightTertiary) + .kerning(0.8) + + HStack(spacing: 7) { + ForEach(Mood.allCases, id: \.self) { mood in + MoodChip(mood: mood, isSelected: selected == mood) { + Haptics.soft() + withAnimation(.spring(duration: 0.3, bounce: 0.3)) { + selected = selected == mood ? nil : mood + } + } + } + } + } + } +} + +struct MoodChip: View { + let mood: Mood + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 5) { + Text(mood.emoji) + .font(.nightMono(12)) + .foregroundColor(isSelected ? mood.color : .nightSecondary) + Text(mood.label) + .font(.nightLabel(12, weight: isSelected ? .semibold : .regular)) + .foregroundColor(isSelected ? .nightPrimary : .nightSecondary) + } + .padding(.horizontal, 11) + .padding(.vertical, 7) + .background( + ZStack { + if isSelected { + Capsule().fill(mood.color.opacity(0.14)) + Capsule().strokeBorder(mood.color.opacity(0.4), lineWidth: 1) + } else { + Capsule().fill(Color.nightRaised) + Capsule().strokeBorder(Color.nightBorder, lineWidth: 1) + } + } + ) + } + } +} + +// MARK: - Post Button + +struct PostButton: View { + let canPost: Bool + let isPosting: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Group { + if isPosting { + ProgressView().tint(.black).frame(width: 20, height: 20) + } else { + Text("posten") + .font(.nightLabel(15, weight: .bold)) + .foregroundColor(canPost ? Color.nightBase : .nightTertiary) + } + } + .frame(width: 74, height: 34) + .background(canPost ? Color.nightPrimary : Color.nightRaised) + .clipShape(Capsule()) + } + .disabled(!canPost || isPosting) + .animation(.easeInOut(duration: 0.2), value: canPost) + } +} + +// MARK: - Countdown + +struct WindowCountdownView: View { + @State private var label = "" + + var body: some View { + HStack(spacing: 5) { + Image(systemName: "clock") + .font(.system(size: 11)) + Text(label) + .font(.nightMono(11)) + } + .foregroundColor(.nightPurple.opacity(0.5)) + .onAppear { tick() } + .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in tick() } + } + + func tick() { + var c = Calendar.current.dateComponents([.year, .month, .day], from: Date()) + c.hour = 5; c.minute = 0; c.second = 0 + guard let end = Calendar.current.date(from: c) else { return } + let diff = max(0, Int(end.timeIntervalSince(Date()))) + label = diff > 0 + ? String(format: "%d:%02d bis 05:00", diff / 60, diff % 60) + : "fenster zu" + } +} diff --git a/ios/thoughts/thoughts/Views/DiaryView.swift b/ios/thoughts/thoughts/Views/DiaryView.swift new file mode 100644 index 0000000..62acd0f --- /dev/null +++ b/ios/thoughts/thoughts/Views/DiaryView.swift @@ -0,0 +1,303 @@ +import Combine +import SwiftUI + +/// Persönliches Tagebuch — alle eigenen Posts, auch die anonymen. +/// Bleibt für immer. Das ist der Retention-Hook. +struct DiaryView: View { + @StateObject private var viewModel = DiaryViewModel() + @EnvironmentObject var appState: AppState + @State private var postsAppeared = false + + var body: some View { + NavigationStack { + ZStack { + Color.nightBase.ignoresSafeArea() + + if viewModel.groupedPosts.isEmpty && !viewModel.isLoading { + DiaryEmptyView() + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + // "Vor genau einem Jahr" — Memory + if let memory = viewModel.yearAgoPost { + MemoryBanner(post: memory) + .padding(.bottom, 4) + } + + ForEach(Array(viewModel.groupedPosts.enumerated()), id: \.element.nightLabel) { groupIndex, group in + // Nacht-Header + DiaryNightHeader(label: group.nightLabel, count: group.posts.count) + .opacity(postsAppeared ? 1 : 0) + .animation( + .spring(duration: 0.35, bounce: 0.1) + .delay(Double(min(groupIndex, 6)) * 0.06), + value: postsAppeared + ) + + ForEach(group.posts) { post in + DiaryPostRow(post: post) { + Task { await viewModel.deletePost(post) } + } + .opacity(postsAppeared ? 1 : 0) + .offset(y: postsAppeared ? 0 : 12) + .animation( + .spring(duration: 0.35, bounce: 0.1) + .delay(Double(min(groupIndex, 6)) * 0.06 + 0.03), + value: postsAppeared + ) + Divider().background(Color.nightBorder).padding(.leading, 16) + } + } + .onAppear { postsAppeared = true } + Color.clear.frame(height: 100) + } + } + .refreshable { await viewModel.load() } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + HStack(spacing: 7) { + Image(systemName: "book.closed.fill") + .foregroundColor(.nightPurple) + Text("tagebuch") + .font(.nightTitle(17)) + .foregroundColor(.nightPrimary) + } + } + } + } + .task { await viewModel.load() } + } +} + +// MARK: - Memory Banner + +struct MemoryBanner: View { + let post: Post + @State private var show = true + + var body: some View { + if show { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Image(systemName: "clock.arrow.circlepath") + .foregroundColor(.nightPurpleSoft) + .font(.system(size: 14)) + Text("vor genau einem jahr") + .font(.nightLabel(12, weight: .semibold)) + .foregroundColor(.nightPurpleSoft) + .kerning(0.5) + Spacer() + Button { withAnimation { show = false } } label: { + Image(systemName: "xmark") + .font(.system(size: 12)) + .foregroundColor(.nightTertiary) + } + } + + Text(post.content) + .font(.nightBody(15)) + .foregroundColor(.nightPrimary.opacity(0.85)) + .lineSpacing(4) + + HStack(spacing: 5) { + if let mood = post.mood { + Text(mood.emoji).font(.nightMono(11)) + Text(mood.label).font(.nightLabel(11)) + } + Text("·") + Text(post.formattedTime) + .font(.nightMono(11)) + } + .foregroundColor(.nightTertiary) + } + .padding(16) + .background( + ZStack { + RoundedRectangle(cornerRadius: 0) + .fill(Color.nightPurple.opacity(0.06)) + RoundedRectangle(cornerRadius: 0) + .fill( + LinearGradient( + colors: [Color.nightPurple.opacity(0.08), .clear], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + ) + .overlay( + Rectangle().fill(Color.nightBorder).frame(height: 1), + alignment: .bottom + ) + } + } +} + +// MARK: - Night Group Header + +struct DiaryNightHeader: View { + let label: String + let count: Int + + var body: some View { + HStack { + Text(label) + .font(.nightLabel(12, weight: .semibold)) + .foregroundColor(.nightSecondary) + .kerning(0.5) + Text("· \(count) Gedanke\(count == 1 ? "" : "n")") + .font(.nightLabel(12)) + .foregroundColor(.nightTertiary) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color.nightSurface) + } +} + +// MARK: - Diary Post Row + +struct DiaryPostRow: View { + let post: Post + let onDelete: () -> Void + @State private var confirmDelete = false + + var body: some View { + HStack(alignment: .top, spacing: 0) { + Rectangle() + .fill(post.mood?.color ?? Color.nightTertiary) + .frame(width: 2) + .padding(.vertical, 14) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(post.formattedTime) + .font(.nightMono(12)) + .foregroundColor(.nightTertiary) + + if post.isAnonymous { + Text("· anonym") + .font(.nightLabel(11)) + .foregroundColor(.nightTertiary) + .italic() + } + + if let mood = post.mood { + Text("· \(mood.emoji) \(mood.label)") + .font(.nightLabel(11)) + .foregroundColor(mood.color.opacity(0.7)) + } + + Spacer() + + if post.resonanceCount > 0 { + HStack(spacing: 3) { + Image(systemName: "heart.fill") + .font(.system(size: 10)) + Text("\(post.resonanceCount)") + .font(.nightLabel(11)) + } + .foregroundColor(.nightRed.opacity(0.7)) + } + } + + Text(post.content) + .font(.nightBody(16)) + .foregroundColor(.nightPrimary.opacity(0.88)) + .lineSpacing(5) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + .contextMenu { + Button(role: .destructive) { + confirmDelete = true + } label: { + Label("Löschen", systemImage: "trash") + } + } + } + .confirmationDialog( + "Post löschen?", + isPresented: $confirmDelete, + titleVisibility: .visible + ) { + Button("Löschen", role: .destructive) { onDelete() } + } message: { + Text("Der Post wird aus dem Feed entfernt, bleibt aber in deinem Tagebuch-Archiv.") + } + } +} + +// MARK: - Empty State + +struct DiaryEmptyView: View { + var body: some View { + VStack(spacing: 18) { + Image(systemName: "book.closed") + .font(.system(size: 48)) + .foregroundColor(.nightTertiary) + Text("noch nichts hier") + .font(.nightTitle(18)) + .foregroundColor(.nightPrimary) + Text("Deine Posts landen hier.\nIn einem Jahr kannst du nachlesen, was dich\nmitten in der Nacht beschäftigt hat.") + .font(.nightBody(15)) + .foregroundColor(.nightSecondary) + .multilineTextAlignment(.center) + .lineSpacing(4) + } + .padding(40) + } +} + +// MARK: - ViewModel + +@MainActor +class DiaryViewModel: ObservableObject { + struct NightGroup { let nightLabel: String; let posts: [Post] } + + @Published var groupedPosts: [NightGroup] = [] + @Published var yearAgoPost: Post? = nil + @Published var isLoading = false + + func load() async { + isLoading = true + defer { isLoading = false } + + do { + let posts = try await supabase.getDiary() + + // Gruppiere nach Nacht (Datum - 2h, damit 2–5 Uhr zur selben Nacht gehört) + let grouped = Dictionary(grouping: posts) { post -> String in + let nightDate = post.createdAt.addingTimeInterval(-2 * 3600) + let f = DateFormatter() + f.locale = Locale(identifier: "de_DE") + f.dateFormat = "EEEE, d. MMMM yyyy" + return f.string(from: nightDate) + } + + groupedPosts = grouped + .sorted { $0.key > $1.key } + .map { NightGroup(nightLabel: $0.key, posts: $0.value) } + + // Memory: Post von vor genau einem Jahr + let oneYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: Date())! + yearAgoPost = posts.first { post in + Calendar.current.isDate(post.createdAt, equalTo: oneYearAgo, toGranularity: .day) + } + } catch { + #if DEBUG + groupedPosts = [NightGroup(nightLabel: "gestern", posts: Post.previews)] + #endif + } + } + + func deletePost(_ post: Post) async { + try? await supabase.softDeletePost(id: post.id) + await load() + } +} diff --git a/ios/thoughts/thoughts/Views/FeedView.swift b/ios/thoughts/thoughts/Views/FeedView.swift new file mode 100644 index 0000000..c840f4b --- /dev/null +++ b/ios/thoughts/thoughts/Views/FeedView.swift @@ -0,0 +1,395 @@ +import SwiftUI + +// MARK: - Feed + +struct FeedView: View { + @StateObject private var viewModel = FeedViewModel() + @EnvironmentObject var appState: AppState + var realtime: RealtimeService? = nil + @State private var postsAppeared = false + + // Gerade Jetzt = posts younger than 10 minutes + var rightNowPosts: [Post] { viewModel.posts.filter { $0.isRightNow } } + var nightPosts: [Post] { viewModel.posts } + + var body: some View { + NavigationStack { + ZStack { + Color.nightBase.ignoresSafeArea() + + if viewModel.posts.isEmpty && !viewModel.isLoading { + EmptyNightView(windowState: appState.windowState) + } else { + ScrollView { + LazyVStack(spacing: 0) { + + // ── GERADE JETZT ────────────────────────────────── + // Nur sichtbar wenn: Fenster offen ODER du hast gerade gepostet + // UND es gibt Leute die gleichzeitig posten + if !rightNowPosts.isEmpty && appState.windowState == .posted { + RightNowSection( + posts: rightNowPosts, + onResonate: { post in + Task { await viewModel.resonate(post) } + } + ) + .padding(.bottom, 2) + } + + // ── TRENNLINIE MIT KONTEXT ────────────────────── + NightContextBar( + windowState: appState.windowState, + totalCount: nightPosts.count, + liveCount: rightNowPosts.count + ) + + // ── HEUTE NACHT ──────────────────────────────── + // Alle Posts dieser Nacht, chronologisch + ForEach(Array(nightPosts.enumerated()), id: \.element.id) { index, post in + PostRowView(post: post) { + Task { await viewModel.resonate(post) } + } + .opacity(postsAppeared ? 1 : 0) + .offset(y: postsAppeared ? 0 : 14) + .animation( + .spring(duration: 0.35, bounce: 0.1) + .delay(Double(min(index, 10)) * 0.04), + value: postsAppeared + ) + Divider() + .background(Color.nightBorder) + .padding(.leading, 16) + } + .onAppear { postsAppeared = true } + + if viewModel.isLoading { + ForEach(0..<5, id: \.self) { _ in + SkeletonPostRow() + Divider().background(Color.nightBorder).padding(.leading, 16) + } + } + + Color.clear.frame(height: 100) + } + } + .refreshable { await viewModel.load() } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + NightlyWordmark() + } + } + } + .task { + await viewModel.load() + if let realtime { + await realtime.startListening { [weak viewModel] post in + viewModel?.prepend(post) + } + } + } + } +} + +// MARK: - Wordmark + +struct NightlyWordmark: View { + var body: some View { + HStack(spacing: 7) { + Text("◐") + .font(.system(size: 15)) + .foregroundColor(.nightPurple) + Text("nightly") + .font(.system(size: 17, weight: .bold, design: .rounded)) + .foregroundColor(.nightPrimary) + } + } +} + +// MARK: - Gerade Jetzt Section +// +// WANN ERSCHEINT DAS? +// → Du hast in den letzten 10 Minuten gepostet +// → Und mindestens eine andere Person auch +// +// WAS IST DER UNTERSCHIED ZU "HEUTE NACHT"? +// → Gerade Jetzt = buchstäblich gerade, gleichzeitig, diese Minute +// → Heute Nacht = alle Posts seit dem Öffnen des Fensters +// +// ANALOGIE: Gerade Jetzt = du bist gerade im selben Raum wie jemand. +// Heute Nacht = der gesamte Raum-Verlauf dieser Nacht. + +struct RightNowSection: View { + let posts: [Post] + let onResonate: (Post) -> Void + + @State private var pulse = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack(spacing: 10) { + // Pulsierender grüner Punkt = LIVE + ZStack { + Circle() + .fill(Color.nightGreen.opacity(0.25)) + .frame(width: 16, height: 16) + .scaleEffect(pulse ? 1.8 : 1.0) + .opacity(pulse ? 0 : 1) + Circle() + .fill(Color.nightGreen) + .frame(width: 7, height: 7) + } + .onAppear { + withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: false)) { + pulse = true + } + } + + Text("gerade jetzt") + .font(.nightLabel(12, weight: .semibold)) + .foregroundColor(.nightGreen) + .kerning(0.8) + + Text("· \(posts.count) \(posts.count == 1 ? "Person" : "Personen") gleichzeitig wach") + .font(.nightLabel(12)) + .foregroundColor(.nightSecondary) + + Spacer() + + // Info-Tooltip + HelpTooltip( + text: "Leute die in den letzten 10 Minuten gepostet haben — ihr seid buchstäblich gleichzeitig wach." + ) + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 12) + + // Horizontal Cards + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(posts) { post in + RightNowCard(post: post, onResonate: { onResonate(post) }) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } + .background( + ZStack { + Color.nightSurface + Color.nightGreen.opacity(0.025) + } + ) + .overlay( + Rectangle() + .fill(Color.nightGreen.opacity(0.15)) + .frame(height: 1), + alignment: .bottom + ) + } +} + +struct RightNowCard: View { + let post: Post + let onResonate: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + if post.isAnonymous { + AnonymousAvatar(size: 26) + } else { + AvatarView(user: post.author, size: 26) + } + Text(post.isAnonymous ? "anonym" : "@\(post.author.username)") + .font(.nightLabel(12)) + .foregroundColor(post.isAnonymous ? .nightSecondary : .nightPrimary) + .lineLimit(1) + Spacer() + if let mood = post.mood { + Text(mood.emoji) + .font(.nightMono(11)) + .foregroundColor(mood.color.opacity(0.7)) + } + } + + Text(post.content) + .font(.nightBody(14)) + .foregroundColor(.nightPrimary.opacity(0.88)) + .lineSpacing(4) + .lineLimit(4) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 0) + + Button(action: onResonate) { + HStack(spacing: 4) { + Image(systemName: post.hasResonated ? "heart.fill" : "heart") + .font(.system(size: 12)) + .foregroundColor(post.hasResonated ? .nightRed : .nightSecondary) + if post.resonanceCount > 0 { + Text("\(post.resonanceCount)") + .font(.nightLabel(11)) + .foregroundColor(.nightSecondary) + } + } + } + } + .padding(14) + .frame(width: 210, height: 148) + .background( + ZStack { + RoundedRectangle(cornerRadius: 14) + .fill(Color.nightRaised) + if let mood = post.mood { + RoundedRectangle(cornerRadius: 14) + .fill( + LinearGradient( + colors: [mood.color.opacity(0.07), .clear], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + RoundedRectangle(cornerRadius: 14) + .strokeBorder(Color.nightGreen.opacity(0.18), lineWidth: 1) + } + ) + } +} + +// MARK: - Context Bar (der Übergang zwischen Gerade Jetzt und Heute Nacht) + +struct NightContextBar: View { + let windowState: AppState.WindowState + let totalCount: Int + let liveCount: Int + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text("heute nacht") + .font(.nightLabel(12, weight: .semibold)) + .foregroundColor(.nightSecondary) + .kerning(0.5) + + if totalCount > 0 { + Text("· \(totalCount) Gedanken") + .font(.nightLabel(12)) + .foregroundColor(.nightTertiary) + } + } + + Text(statusSubtitle) + .font(.nightLabel(11)) + .foregroundColor(.nightTertiary) + } + + Spacer() + + // Window status pill + HStack(spacing: 5) { + Circle() + .fill(windowState == .open ? Color.nightGreen : Color.nightTertiary) + .frame(width: 6, height: 6) + Text(windowState == .open ? "offen" : "geschlossen") + .font(.nightLabel(11)) + .foregroundColor(.nightSecondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.nightRaised) + .clipShape(Capsule()) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .overlay( + Rectangle() + .fill(Color.nightBorder) + .frame(height: 1), + alignment: .bottom + ) + } + + var statusSubtitle: String { + switch windowState { + case .open: return "Du kannst noch posten — bis 05:00" + case .posted: return "Dein Post ist sichtbar bis morgen früh" + case .closed: return "Fenster öffnet später heute Nacht" + case .missed: return "Nächste Chance: heute Nacht" + } + } +} + +// MARK: - Help Tooltip + +struct HelpTooltip: View { + let text: String + @State private var show = false + + var body: some View { + Button { + withAnimation(.spring(duration: 0.3)) { show.toggle() } + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + withAnimation { show = false } + } + } label: { + Image(systemName: "info.circle") + .font(.system(size: 13)) + .foregroundColor(.nightTertiary) + } + .overlay(alignment: .topTrailing) { + if show { + Text(text) + .font(.nightBody(12)) + .foregroundColor(.nightPrimary) + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.nightRaised) + .shadow(color: .black.opacity(0.4), radius: 8, y: 4) + ) + .frame(width: 200) + .offset(x: -160, y: 28) + .transition(.opacity.combined(with: .scale(scale: 0.9, anchor: .topTrailing))) + .zIndex(100) + } + } + } +} + +// MARK: - Empty State + +struct EmptyNightView: View { + let windowState: AppState.WindowState + + var body: some View { + VStack(spacing: 20) { + Text("◐") + .font(.system(size: 52)) + .foregroundColor(.nightPurple.opacity(0.4)) + + VStack(spacing: 8) { + Text(windowState == .open ? "sei der erste heute nacht" : "noch ruhig hier") + .font(.nightTitle(19)) + .foregroundColor(.nightPrimary) + + Text(windowState == .open + ? "Das Fenster ist offen.\nPoste einen Gedanken — andere sind auch wach." + : "Wenn dein Fenster öffnet, kannst du posten.\nErst dann siehst du alle anderen." + ) + .font(.nightBody(15)) + .foregroundColor(.nightSecondary) + .multilineTextAlignment(.center) + .lineSpacing(4) + } + } + .padding(40) + } +} diff --git a/ios/thoughts/thoughts/Views/LegalView.swift b/ios/thoughts/thoughts/Views/LegalView.swift new file mode 100644 index 0000000..ad161d2 --- /dev/null +++ b/ios/thoughts/thoughts/Views/LegalView.swift @@ -0,0 +1,232 @@ +import SwiftUI + +/// Impressum + Datenschutzerklärung +/// ⚠️ Muss vor dem Launch von einem Anwalt geprüft/ergänzt werden! +/// Kosten: ca. 300–500€ einmalig für Impressum + AGB + DSGVO-Datenschutzerklärung +struct LegalView: View { + @Environment(\.dismiss) var dismiss + @State private var tab = 0 + + var body: some View { + NavigationStack { + ZStack { + Color.nightBase.ignoresSafeArea() + + VStack(spacing: 0) { + // Tab Switcher + HStack(spacing: 0) { + ForEach(["impressum", "datenschutz", "nutzungsbedingungen"], id: \.self) { label in + let idx = ["impressum", "datenschutz", "nutzungsbedingungen"].firstIndex(of: label)! + Button(label) { tab = idx } + .font(.nightLabel(12, weight: tab == idx ? .semibold : .regular)) + .foregroundColor(tab == idx ? .nightPrimary : .nightSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .overlay( + Rectangle() + .fill(tab == idx ? Color.nightPurple : .clear) + .frame(height: 2), + alignment: .bottom + ) + } + } + .padding(.horizontal, 16) + .overlay(Rectangle().fill(Color.nightBorder).frame(height: 1), alignment: .bottom) + + ScrollView { + VStack(alignment: .leading, spacing: 0) { + switch tab { + case 0: ImpressumContent() + case 1: DatenschutzContent() + default: NutzungsbedingungenContent() + } + } + .padding(20) + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Schließen") { dismiss() } + .foregroundColor(.nightSecondary) + } + } + } + .preferredColorScheme(.dark) + } +} + +// MARK: - Impressum + +struct ImpressumContent: View { + var body: some View { + LegalSection(title: "Impressum") { + // ⚠️ PFLICHTANGABEN — vor Launch ausfüllen! + LegalParagraph(title: "Angaben gemäß § 5 TMG") { + """ + [DEIN NAME] + [STRASSE HAUSNUMMER] + [PLZ ORT] + Deutschland + + ⚠️ Vor Launch ausfüllen! + """ + } + LegalParagraph(title: "Kontakt") { + """ + E-Mail: legal@xxx.dk0.dev + + ⚠️ Vor Launch ausfüllen! + """ + } + LegalParagraph(title: "Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV") { + "[DEIN NAME], [ADRESSE]" + } + LegalParagraph(title: "Hinweis") { + """ + Diese App ist ein privates Projekt. Für die Richtigkeit, \ + Vollständigkeit und Aktualität der Inhalte kann keine Gewähr übernommen werden. + """ + } + } + } +} + +// MARK: - Datenschutz + +struct DatenschutzContent: View { + var body: some View { + LegalSection(title: "Datenschutzerklärung") { + LegalParagraph(title: "⚠️ Hinweis") { + """ + Diese Datenschutzerklärung ist ein Entwurf und muss vor dem Launch \ + von einem Datenschutzanwalt geprüft und vervollständigt werden. \ + Kosten: ca. 300–500€. + """ + } + LegalParagraph(title: "Verantwortlicher") { + "[DEIN NAME], [ADRESSE], [E-MAIL]" + } + LegalParagraph(title: "Welche Daten wir speichern") { + """ + • E-Mail-Adresse (für Account & Passwort-Reset) + • Benutzername und Anzeigename + • Posts, Reaktionen, Kommentare (Inhalte die du selbst erstellst) + • Push-Token (für Benachrichtigungen, optional) + • IP-Adresse in Server-Logs (max. 14 Tage) + """ + } + LegalParagraph(title: "Wofür wir Daten verwenden") { + """ + • Betrieb des Dienstes (Authentifizierung, Feed, Benachrichtigungen) + • Moderation (Meldungen von Inhalten) + • Keine Weitergabe an Dritte außer für den Betrieb notwendige Dienste + """ + } + LegalParagraph(title: "Serverstandort") { + "Alle Daten werden auf Servern in der EU gespeichert." + } + LegalParagraph(title: "Deine Rechte (DSGVO)") { + """ + • Auskunft über gespeicherte Daten: legal@xxx.dk0.dev + • Berichtigung falscher Daten + • Löschung: Account in den Einstellungen löschen — entfernt alle deine Daten sofort + • Datenübertragbarkeit: auf Anfrage per E-Mail + • Widerspruch gegen Verarbeitung: legal@xxx.dk0.dev + • Beschwerde bei der Datenschutzbehörde + """ + } + LegalParagraph(title: "Datenlöschung") { + """ + Posts werden 14 Stunden nach Erstellung aus dem öffentlichen Feed entfernt. \ + Dein persönliches Tagebuch behältst du so lange du möchtest. \ + Account-Löschung entfernt alle Daten dauerhaft und unwiderruflich. + """ + } + LegalParagraph(title: "Cookies / Tracking") { + "Wir verwenden keine Cookies, keine Tracker, keine Werbenetze." + } + } + } +} + +// MARK: - Nutzungsbedingungen + +struct NutzungsbedingungenContent: View { + var body: some View { + LegalSection(title: "Nutzungsbedingungen") { + LegalParagraph(title: "⚠️ Entwurf") { + "Diese Nutzungsbedingungen sind ein Entwurf und müssen vor dem Launch von einem Anwalt geprüft werden." + } + LegalParagraph(title: "Nutzung") { + """ + nightly ist ein Dienst für Personen ab 17 Jahren. \ + Du bist für die Inhalte die du postest selbst verantwortlich. + """ + } + LegalParagraph(title: "Verbotene Inhalte") { + """ + Folgende Inhalte sind verboten: + • Hassrede, Diskriminierung, Bedrohung + • Belästigung oder Mobbing + • Illegale Inhalte jeglicher Art + • Spam oder kommerzielle Werbung + • Inhalte die andere Personen ohne deren Zustimmung zeigen + """ + } + LegalParagraph(title: "Moderation") { + """ + Gemeldete Inhalte werden geprüft und können ohne Vorankündigung entfernt werden. \ + Bei schwerwiegenden Verstößen behalten wir uns die Sperrung des Accounts vor. + """ + } + LegalParagraph(title: "Haftungsausschluss") { + """ + Wir übernehmen keine Haftung für nutzergenerierte Inhalte. \ + Der Dienst wird ohne Gewähr für Verfügbarkeit bereitgestellt. + """ + } + } + } +} + +// MARK: - Reusable components + +struct LegalSection: View { + let title: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Text(title) + .font(.nightTitle(22)) + .foregroundColor(.nightPrimary) + .padding(.bottom, 4) + content + } + } +} + +struct LegalParagraph: View { + let title: String + let text: String + + init(title: String, _ text: () -> String) { + self.title = title + self.text = text() + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.nightLabel(14, weight: .semibold)) + .foregroundColor(.nightPrimary) + Text(text) + .font(.nightBody(14)) + .foregroundColor(.nightSecondary) + .lineSpacing(4) + .fixedSize(horizontal: false, vertical: true) + } + } +} diff --git a/ios/thoughts/thoughts/Views/MainTabView.swift b/ios/thoughts/thoughts/Views/MainTabView.swift new file mode 100644 index 0000000..3042f30 --- /dev/null +++ b/ios/thoughts/thoughts/Views/MainTabView.swift @@ -0,0 +1,208 @@ +import SwiftUI + +struct MainTabView: View { + @EnvironmentObject var appState: AppState + @StateObject private var realtime = RealtimeService() + @State private var selectedTab = 0 + @State private var showCompose = false + @State private var showSettings = false + + var body: some View { + ZStack(alignment: .bottom) { + Color.nightBase.ignoresSafeArea() + + // Content + TabContent( + selectedTab: selectedTab, + realtime: realtime + ) + .environmentObject(appState) + + // Floating Tab Bar + FloatingTabBar( + selectedTab: $selectedTab, + windowState: appState.windowState, + onCompose: { showCompose = true }, + onSettings: { showSettings = true } + ) + } + .ignoresSafeArea(edges: .bottom) + .sheet(isPresented: $showCompose) { + ComposeView().environmentObject(appState) + } + .sheet(isPresented: $showSettings) { + SettingsView().environmentObject(appState) + } + .onDisappear { + Task { await realtime.stopListening() } + } + } +} + +// MARK: - Tab Content + +private struct TabContent: View { + let selectedTab: Int + @ObservedObject var realtime: RealtimeService + @EnvironmentObject var appState: AppState + + var body: some View { + ZStack { + FeedView(realtime: realtime) + .environmentObject(appState) + .opacity(selectedTab == 0 ? 1 : 0) + .scaleEffect(selectedTab == 0 ? 1 : 0.97) + .allowsHitTesting(selectedTab == 0) + + DiaryView() + .environmentObject(appState) + .opacity(selectedTab == 1 ? 1 : 0) + .scaleEffect(selectedTab == 1 ? 1 : 0.97) + .allowsHitTesting(selectedTab == 1) + + ProfileView( + user: appState.currentUser ?? .preview, + isCurrentUser: true + ) + .environmentObject(appState) + .opacity(selectedTab == 2 ? 1 : 0) + .scaleEffect(selectedTab == 2 ? 1 : 0.97) + .allowsHitTesting(selectedTab == 2) + } + .animation(.spring(duration: 0.25, bounce: 0.1), value: selectedTab) + } +} + +// MARK: - Floating Tab Bar + +struct FloatingTabBar: View { + @Binding var selectedTab: Int + let windowState: AppState.WindowState + let onCompose: () -> Void + let onSettings: () -> Void + + var body: some View { + HStack(spacing: 0) { + // Feed + TabIcon(icon: "moon.stars", activeIcon: "moon.stars.fill", isSelected: selectedTab == 0) { + Haptics.select() + selectedTab = 0 + } + + Spacer() + + // Diary + TabIcon(icon: "book.closed", activeIcon: "book.closed.fill", isSelected: selectedTab == 1) { + Haptics.select() + selectedTab = 1 + } + + Spacer() + + // Center: Compose + ComposeTabButton(windowState: windowState, onTap: onCompose) + + Spacer() + + // Profile + TabIcon(icon: "person", activeIcon: "person.fill", isSelected: selectedTab == 2) { + Haptics.select() + selectedTab = 2 + } + + Spacer() + + // Settings + TabIcon(icon: "gearshape", activeIcon: "gearshape.fill", isSelected: false) { + Haptics.select() + onSettings() + } + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .padding(.bottom, 18) + .background( + Rectangle() + .fill(.ultraThinMaterial.opacity(0.8)) + .background(Color.nightBase.opacity(0.85)) + .ignoresSafeArea() + ) + .overlay( + Rectangle().fill(Color.nightBorder).frame(height: 1), + alignment: .top + ) + } +} + +struct TabIcon: View { + let icon: String + let activeIcon: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: isSelected ? activeIcon : icon) + .font(.system(size: 21)) + .foregroundColor(isSelected ? .nightPrimary : .nightSecondary) + .frame(width: 44, height: 44) + } + } +} + +struct ComposeTabButton: View { + let windowState: AppState.WindowState + let onTap: () -> Void + @State private var glow = false + + var body: some View { + Button { + guard windowState == .open else { return } + Haptics.medium() + onTap() + } label: { + ZStack { + if windowState == .open { + Circle() + .fill(Color.nightPurple.opacity(0.18)) + .frame(width: 62, height: 62) + .scaleEffect(glow ? 1.15 : 1.0) + .animation(.easeInOut(duration: 2.2).repeatForever(autoreverses: true), value: glow) + } + Circle() + .fill(buttonFill) + .frame(width: 50, height: 50) + Image(systemName: buttonIcon) + .font(.system(size: 19, weight: .semibold)) + .foregroundColor(.white) + } + } + .onAppear { glow = true } + .animation(.easeInOut(duration: 0.4), value: windowState) + } + + var buttonFill: AnyShapeStyle { + switch windowState { + case .open: + return AnyShapeStyle(LinearGradient( + colors: [Color(hex: "8B5CF6"), Color(hex: "6D28D9")], + startPoint: .topLeading, endPoint: .bottomTrailing + )) + case .posted: + return AnyShapeStyle(LinearGradient( + colors: [Color(hex: "059669"), Color(hex: "047857")], + startPoint: .top, endPoint: .bottom + )) + default: + return AnyShapeStyle(Color.nightRaised) + } + } + + var buttonIcon: String { + switch windowState { + case .open: return "plus" + case .posted: return "checkmark" + default: return "moon.zzz" + } + } +} diff --git a/ios/thoughts/thoughts/Views/OnboardingView.swift b/ios/thoughts/thoughts/Views/OnboardingView.swift new file mode 100644 index 0000000..d6ec48f --- /dev/null +++ b/ios/thoughts/thoughts/Views/OnboardingView.swift @@ -0,0 +1,306 @@ +import SwiftUI + +struct OnboardingView: View { + @EnvironmentObject var appState: AppState + @State private var phase: Phase = .welcome + @State private var isLogin = false + + enum Phase { case welcome, auth } + + var body: some View { + ZStack { + Color.nightBase.ignoresSafeArea() + StarField() + + VStack(spacing: 0) { + Spacer() + + switch phase { + case .welcome: + WelcomeScreen() + .transition(.opacity) + Spacer() + WelcomeActions( + onStart: { + isLogin = false + withAnimation(.spring(duration: 0.4)) { phase = .auth } + }, + onLogin: { + isLogin = true + withAnimation(.spring(duration: 0.4)) { phase = .auth } + } + ) + + case .auth: + AuthScreen(isLogin: $isLogin) + .environmentObject(appState) + .transition(.move(edge: .trailing).combined(with: .opacity)) + Spacer() + } + } + } + .preferredColorScheme(.dark) + } +} + +// MARK: - Welcome + +struct WelcomeScreen: View { + @State private var appeared = false + + var body: some View { + VStack(spacing: 28) { + ZStack { + ForEach([130, 100, 70], id: \.self) { size in + Circle() + .fill(Color.nightPurple.opacity(size == 70 ? 0 : (size == 100 ? 0.08 : 0.04))) + .frame(width: CGFloat(size), height: CGFloat(size)) + } + Text("◐") + .font(.system(size: 52)) + .foregroundColor(.nightPurpleSoft) + } + .scaleEffect(appeared ? 1 : 0.75) + .opacity(appeared ? 1 : 0) + + VStack(spacing: 12) { + Text("nightly") + .font(.system(size: 44, weight: .bold, design: .rounded)) + .foregroundColor(.nightPrimary) + + VStack(spacing: 5) { + Text("Zwischen 2 und 5 Uhr.") + Text("Kein Filter. Keine Maske.") + Text("Nur echte Gedanken.") + } + .font(.nightBody(17)) + .foregroundColor(.nightSecondary) + .multilineTextAlignment(.center) + .lineSpacing(3) + } + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 10) + } + .onAppear { + withAnimation(.spring(duration: 0.8, bounce: 0.25).delay(0.1)) { appeared = true } + } + } +} + +struct WelcomeActions: View { + let onStart: () -> Void + let onLogin: () -> Void + + var body: some View { + VStack(spacing: 12) { + Button("loslegen", action: onStart).buttonStyle(NightlyPrimaryButton()) + Button("ich hab schon einen account", action: onLogin) + .font(.nightLabel(14)) + .foregroundColor(.nightSecondary) + } + .padding(.horizontal, 24) + .padding(.bottom, 52) + } +} + +// MARK: - Auth Screen + +struct AuthScreen: View { + @EnvironmentObject var appState: AppState + @Binding var isLogin: Bool + + // Registrierung + @State private var username = "" + @State private var displayName = "" + @State private var email = "" + @State private var password = "" + + @State private var isLoading = false + @State private var error: String? + + var body: some View { + VStack(spacing: 22) { + Text(isLogin ? "willkommen zurück" : "mitmachen") + .font(.nightTitle(28)) + .foregroundColor(.nightPrimary) + + VStack(spacing: 10) { + if !isLogin { + NightlyField(text: $displayName, placeholder: "anzeigename", icon: "person") + NightlyField(text: $username, placeholder: "benutzername", icon: "at") + .textInputAutocapitalization(.never).autocorrectionDisabled() + } + + NightlyField(text: $email, placeholder: "e-mail", icon: "envelope") + .textInputAutocapitalization(.never) + .keyboardType(.emailAddress) + .autocorrectionDisabled() + + NightlyField(text: $password, placeholder: "passwort", icon: "lock", isSecure: true) + } + .padding(.horizontal, 24) + + if let err = error { + Text(err) + .font(.nightLabel(13)) + .foregroundColor(.nightRed) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + } + + Button(isLogin ? "einloggen" : "account erstellen") { + Task { await submit() } + } + .buttonStyle(NightlyPrimaryButton(isLoading: isLoading)) + .disabled(isLoading) + .padding(.horizontal, 24) + + Button(isLogin ? "noch kein account?" : "schon dabei?") { + withAnimation { isLogin.toggle() } + } + .font(.nightLabel(14)) + .foregroundColor(.nightSecondary) + + // Rechtliches + LegalNotice() + } + } + + func submit() async { + guard !email.isEmpty && !password.isEmpty else { + error = "Bitte alle Felder ausfüllen." + return + } + isLoading = true + error = nil + defer { isLoading = false } + + do { + if isLogin { + try await appState.signIn(email: email, password: password) + } else { + guard !username.isEmpty && !displayName.isEmpty else { + error = "Bitte alle Felder ausfüllen." + return + } + guard username.count >= 3 else { + error = "Benutzername muss mindestens 3 Zeichen haben." + return + } + guard password.count >= 8 else { + error = "Passwort muss mindestens 8 Zeichen haben." + return + } + try await appState.signUp( + email: email, + password: password, + username: username.lowercased(), + displayName: displayName + ) + } + } catch { + self.error = error.localizedDescription + } + } +} + +struct LegalNotice: View { + @State private var showLegal = false + + var body: some View { + VStack(spacing: 4) { + Text("Mit der Registrierung stimmst du zu:") + .font(.nightLabel(11)) + .foregroundColor(.nightTertiary) + + HStack(spacing: 4) { + Button("Nutzungsbedingungen") { showLegal = true } + Text("·") + Button("Datenschutzerklärung") { showLegal = true } + } + .font(.nightLabel(11, weight: .medium)) + .foregroundColor(.nightSecondary) + } + .multilineTextAlignment(.center) + .sheet(isPresented: $showLegal) { + LegalView() + } + } +} + +// MARK: - Reusable components + +struct NightlyField: View { + @Binding var text: String + let placeholder: String + let icon: String + var isSecure = false + + var body: some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 15)) + .foregroundColor(.nightSecondary) + .frame(width: 18) + + Group { + if isSecure { SecureField(placeholder, text: $text) } + else { TextField(placeholder, text: $text) } + } + .font(.nightBody(16)) + .foregroundColor(.nightPrimary) + } + .padding(16) + .background(Color.nightSurface) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Color.nightBorder, lineWidth: 1)) + .tint(.nightPurpleSoft) + } +} + +struct NightlyPrimaryButton: ButtonStyle { + var isLoading = false + func makeBody(configuration: Configuration) -> some View { + Group { + if isLoading { ProgressView().tint(.nightBase).frame(maxWidth: .infinity, maxHeight: 52) } + else { + configuration.label + .font(.nightLabel(17, weight: .semibold)) + .foregroundColor(.nightBase) + .frame(maxWidth: .infinity).frame(height: 52) + } + } + .background(Color.nightPrimary.opacity(configuration.isPressed ? 0.85 : 1)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .scaleEffect(configuration.isPressed ? 0.98 : 1) + .animation(.spring(duration: 0.2), value: configuration.isPressed) + } +} + +struct StarField: View { + struct Star: Identifiable { + let id: Int; let x, y, size, opacity: CGFloat + } + private let stars: [Star] = (0..<120).map { + Star(id: $0, + x: .random(in: 0...1), + y: .random(in: 0...1), + size: .random(in: 1...2.5), + opacity: .random(in: 0.07...0.3)) + } + @State private var twinkle = false + var body: some View { + GeometryReader { geo in + ForEach(stars) { s in + Circle().fill(Color.white) + .frame(width: s.size, height: s.size) + .opacity(twinkle && s.id % 5 == 0 ? s.opacity * 0.3 : s.opacity) + .position(x: s.x * geo.size.width, y: s.y * geo.size.height) + } + } + .ignoresSafeArea() + .onAppear { + withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) { twinkle = true } + } + } +} diff --git a/ios/thoughts/thoughts/Views/PostRowView.swift b/ios/thoughts/thoughts/Views/PostRowView.swift new file mode 100644 index 0000000..dae729f --- /dev/null +++ b/ios/thoughts/thoughts/Views/PostRowView.swift @@ -0,0 +1,292 @@ +import SwiftUI + +// MARK: - Post Row + +struct PostRowView: View { + let post: Post + let onResonate: () -> Void + var onReport: (() -> Void)? = nil + + @State private var showReport = false + + var body: some View { + HStack(alignment: .top, spacing: 0) { + // Mood accent bar — der einzige echte Farbakzent im Feed + Rectangle() + .fill(post.mood?.color ?? Color.nightTertiary) + .frame(width: 2) + .padding(.vertical, 18) + + VStack(alignment: .leading, spacing: 11) { + // Author + HStack(spacing: 9) { + if post.isAnonymous { + AnonymousAvatar(size: 32) + } else { + AvatarView(user: post.author, size: 32) + } + + VStack(alignment: .leading, spacing: 1) { + if post.isAnonymous { + Text("anonym") + .font(.nightLabel(13)) + .foregroundColor(.nightSecondary) + .italic() + } else { + Text(post.author.displayName) + .font(.nightLabel(14, weight: .semibold)) + .foregroundColor(.nightPrimary) + } + } + + Spacer() + + HStack(spacing: 8) { + if let mood = post.mood { + Text(mood.emoji) + .font(.nightMono(11)) + .foregroundColor(mood.color.opacity(0.8)) + } + Text(post.formattedTime) + .font(.nightMono(11)) + .foregroundColor(.nightTertiary) + + // Drei-Punkte-Menü für Report + Menu { + Button(role: .destructive) { + showReport = true + } label: { + Label("Melden", systemImage: "flag") + } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 13)) + .foregroundColor(.nightTertiary) + .padding(4) + } + } + } + + // Content + Text(post.content) + .font(.nightBody(16)) + .foregroundColor(.nightPrimary.opacity(0.9)) + .lineSpacing(5) + .fixedSize(horizontal: false, vertical: true) + + // Resonance + ResonanceButton( + count: post.resonanceCount, + isActive: post.hasResonated, + action: onResonate + ) + } + .padding(.leading, 14) + .padding(.trailing, 16) + .padding(.vertical, 16) + } + .sheet(isPresented: $showReport) { + ReportSheet(postId: post.id) + } + } +} + +// MARK: - Resonance Button + +struct ResonanceButton: View { + let count: Int + let isActive: Bool + let action: () -> Void + + @State private var scale: CGFloat = 1.0 + + var body: some View { + Button { + Haptics.light() + withAnimation(.spring(duration: 0.25, bounce: 0.7)) { scale = 1.4 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + withAnimation(.spring(duration: 0.2)) { scale = 1.0 } + } + action() + } label: { + HStack(spacing: 5) { + Image(systemName: isActive ? "heart.fill" : "heart") + .font(.system(size: 14)) + .foregroundColor(isActive ? .nightRed : .nightSecondary) + .scaleEffect(scale) + + Text(count > 0 ? "\(count)" : "hat mich getroffen") + .font(.nightLabel(13)) + .foregroundColor(isActive ? .nightRed : .nightSecondary) + } + .padding(.vertical, 5) + .padding(.horizontal, count > 0 || isActive ? 10 : 0) + .background( + Capsule() + .fill(isActive ? Color.nightRed.opacity(0.1) : Color.clear) + ) + } + .animation(.easeInOut(duration: 0.2), value: isActive) + } +} + +// MARK: - Avatar + +struct AvatarView: View { + let user: User + let size: CGFloat + + var body: some View { + Group { + if let url = user.avatarURL { + AsyncImage(url: url) { img in img.resizable().scaledToFill() } + placeholder: { initials } + } else { initials } + } + .frame(width: size, height: size) + .clipShape(Circle()) + } + + var initials: some View { + ZStack { + Circle().fill(Color.nightPurple.opacity(0.18)) + Text(String(user.displayName.prefix(1)).uppercased()) + .font(.system(size: size * 0.38, weight: .semibold)) + .foregroundColor(.nightPurpleSoft) + } + } +} + +struct AnonymousAvatar: View { + let size: CGFloat + var body: some View { + ZStack { + Circle().fill(Color.nightRaised) + Image(systemName: "questionmark") + .font(.system(size: size * 0.35, weight: .semibold)) + .foregroundColor(.nightSecondary) + } + .frame(width: size, height: size) + } +} + +// MARK: - Report Sheet + +struct ReportSheet: View { + let postId: String + @Environment(\.dismiss) var dismiss + @State private var selected: ReportReason? = nil + @State private var submitted = false + @State private var isLoading = false + + enum ReportReason: String, CaseIterable { + case hate = "Hassrede / Diskriminierung" + case harassment = "Belästigung / Mobbing" + case selfharm = "Selbstverletzung / Suizid" + case illegal = "Illegale Inhalte" + case spam = "Spam" + case other = "Sonstiges" + } + + var body: some View { + NavigationStack { + ZStack { + Color.nightSurface.ignoresSafeArea() + + if submitted { + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 48)) + .foregroundColor(.nightGreen) + Text("Danke für deine Meldung") + .font(.nightTitle(18)) + .foregroundColor(.nightPrimary) + Text("Wir prüfen den Inhalt so schnell wie möglich.") + .font(.nightBody(14)) + .foregroundColor(.nightSecondary) + .multilineTextAlignment(.center) + Button("Schließen") { dismiss() } + .foregroundColor(.nightPurpleSoft) + .padding(.top, 8) + } + .padding(40) + } else { + VStack(alignment: .leading, spacing: 0) { + Text("Warum möchtest du das melden?") + .font(.nightTitle(17)) + .foregroundColor(.nightPrimary) + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 16) + + ForEach(ReportReason.allCases, id: \.self) { reason in + Button { + selected = reason + } label: { + HStack { + Text(reason.rawValue) + .font(.nightBody(15)) + .foregroundColor(.nightPrimary) + Spacer() + if selected == reason { + Image(systemName: "checkmark") + .foregroundColor(.nightPurpleSoft) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + .background(selected == reason ? Color.nightPurple.opacity(0.08) : Color.clear) + } + Divider().background(Color.nightBorder) + } + + Spacer() + + Button { + guard let reason = selected else { return } + Task { await submit(reason: reason) } + } label: { + Group { + if isLoading { + ProgressView().tint(.black) + } else { + Text("Melden") + .font(.nightLabel(16, weight: .semibold)) + .foregroundColor(.black) + } + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(selected != nil ? Color.nightPrimary : Color.nightRaised) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .disabled(selected == nil || isLoading) + .padding(.horizontal, 20) + .padding(.bottom, 32) + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Abbrechen") { dismiss() } + .foregroundColor(.nightSecondary) + } + } + } + .presentationDetents([.medium]) + .preferredColorScheme(.dark) + } + + func submit(reason: ReportReason) async { + isLoading = true + defer { isLoading = false } + do { + try await supabase.reportPost(postId: postId, reason: reason.rawValue, details: nil) + submitted = true + } catch { + // Fehler still ignorieren — Meldung trotzdem als abgeschlossen zeigen + submitted = true + } + } +} diff --git a/ios/thoughts/thoughts/Views/ProfileView.swift b/ios/thoughts/thoughts/Views/ProfileView.swift new file mode 100644 index 0000000..92011ce --- /dev/null +++ b/ios/thoughts/thoughts/Views/ProfileView.swift @@ -0,0 +1,356 @@ +import SwiftUI + +struct ProfileView: View { + let user: User + let isCurrentUser: Bool + @EnvironmentObject var appState: AppState + @StateObject private var viewModel: ProfileViewModel + @State private var showSettings = false + @State private var postsAppeared = false + + init(user: User, isCurrentUser: Bool) { + self.user = user + self.isCurrentUser = isCurrentUser + _viewModel = StateObject(wrappedValue: ProfileViewModel(userIdString: user.id)) + } + + var body: some View { + NavigationStack { + ZStack { + Color.nightBase.ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + if viewModel.isLoading { + SkeletonProfileHeader() + } else { + ProfileHeader( + user: user, + streak: viewModel.streak, + isCurrentUser: isCurrentUser + ) + } + + Divider().background(Color.nightBorder) + + // Posts + if viewModel.isLoading { + ForEach(0..<4, id: \.self) { _ in + SkeletonPostRow() + Divider().background(Color.nightBorder).padding(.leading, 16) + } + } else if viewModel.posts.isEmpty { + EmptyProfilePosts() + } else { + LazyVStack(spacing: 0) { + ForEach(Array(viewModel.posts.enumerated()), id: \.element.id) { index, post in + PostRowView(post: post) {} + .opacity(postsAppeared ? 1 : 0) + .offset(y: postsAppeared ? 0 : 16) + .animation( + .spring(duration: 0.4, bounce: 0.12) + .delay(Double(min(index, 8)) * 0.05), + value: postsAppeared + ) + Divider().background(Color.nightBorder).padding(.leading, 16) + } + } + .onAppear { postsAppeared = true } + } + + Color.clear.frame(height: 100) + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if isCurrentUser { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showSettings = true + } label: { + Image(systemName: "gearshape") + .foregroundColor(.nightSecondary) + } + } + } + } + .sheet(isPresented: $showSettings) { + SettingsView().environmentObject(appState) + } + } + .task { await viewModel.load() } + } +} + +// MARK: - Profile Header + +struct ProfileHeader: View { + let user: User + let streak: Int + let isCurrentUser: Bool + + @State private var isFollowing: Bool + @State private var isFollowLoading = false + + // Staggered entrance + @State private var avatarAppeared = false + @State private var infoAppeared = false + @State private var statsAppeared = false + @State private var actionAppeared = false + + init(user: User, streak: Int, isCurrentUser: Bool) { + self.user = user + self.streak = streak + self.isCurrentUser = isCurrentUser + _isFollowing = State(initialValue: user.isFollowing) + } + + var body: some View { + VStack(spacing: 0) { + VStack(spacing: 16) { + // Avatar mit Glow Ring + ZStack { + Circle() + .fill( + RadialGradient( + colors: [Color.nightPurple.opacity(0.12), .clear], + center: .center, + startRadius: 34, + endRadius: 52 + ) + ) + .frame(width: 96, height: 96) + + Circle() + .strokeBorder( + LinearGradient( + colors: [Color.nightPurple.opacity(0.35), Color.nightPurpleSoft.opacity(0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 1.5 + ) + .frame(width: 82, height: 82) + + AvatarView(user: user, size: 76) + } + .scaleEffect(avatarAppeared ? 1 : 0.8) + .opacity(avatarAppeared ? 1 : 0) + + // Name, Username, Bio + VStack(spacing: 4) { + Text(user.displayName) + .font(.nightTitle(22)) + .foregroundColor(.nightPrimary) + Text("@\(user.username)") + .font(.nightLabel(14)) + .foregroundColor(.nightSecondary) + if let bio = user.bio { + Text(bio) + .font(.nightBody(14)) + .foregroundColor(.nightPrimary.opacity(0.75)) + .multilineTextAlignment(.center) + .padding(.top, 4) + } + } + .opacity(infoAppeared ? 1 : 0) + .offset(y: infoAppeared ? 0 : 8) + + // Stats + HStack(spacing: 36) { + ProfileStat(value: user.postCount, label: "nächte") + ProfileStat(value: user.followerCount, label: "follower") + ProfileStat(value: user.followingCount, label: "following") + } + .opacity(statsAppeared ? 1 : 0) + .offset(y: statsAppeared ? 0 : 10) + + // Streak + if streak > 0 { + StreakBadge(streak: streak) + .opacity(actionAppeared ? 1 : 0) + .offset(y: actionAppeared ? 0 : 8) + } + + // Action button + if isCurrentUser { + Button("profil bearbeiten") {} + .font(.nightLabel(14, weight: .medium)) + .foregroundColor(.nightPrimary) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(Color.nightRaised) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.nightBorder, lineWidth: 1) + ) + .padding(.horizontal, 48) + .opacity(actionAppeared ? 1 : 0) + .offset(y: actionAppeared ? 0 : 8) + } else { + FollowButton( + isFollowing: $isFollowing, + isLoading: $isFollowLoading, + onToggle: { Task { await toggleFollow() } } + ) + .opacity(actionAppeared ? 1 : 0) + .offset(y: actionAppeared ? 0 : 8) + } + } + .padding(.horizontal, 20) + .padding(.top, 28) + .padding(.bottom, 20) + } + .onAppear { + withAnimation(.spring(duration: 0.6, bounce: 0.3).delay(0.05)) { avatarAppeared = true } + withAnimation(.spring(duration: 0.6, bounce: 0.2).delay(0.15)) { infoAppeared = true } + withAnimation(.spring(duration: 0.5, bounce: 0.2).delay(0.25)) { statsAppeared = true } + withAnimation(.spring(duration: 0.5, bounce: 0.2).delay(0.35)) { actionAppeared = true } + } + } + + func toggleFollow() async { + Haptics.light() + isFollowLoading = true + defer { isFollowLoading = false } + guard let uid = UUID(uuidString: user.id) else { return } + do { + if isFollowing { + try await supabase.unfollow(userId: uid) + } else { + try await supabase.follow(userId: uid) + } + withAnimation(.spring(duration: 0.3, bounce: 0.4)) { + isFollowing.toggle() + } + Haptics.success() + } catch { + Haptics.error() + } + } +} + +// MARK: - Follow Button + +struct FollowButton: View { + @Binding var isFollowing: Bool + @Binding var isLoading: Bool + let onToggle: () -> Void + + var body: some View { + Button(action: onToggle) { + Group { + if isLoading { + ProgressView().tint(isFollowing ? .nightPrimary : .nightBase) + } else { + Text(isFollowing ? "entfolgen" : "folgen") + .font(.nightLabel(14, weight: .semibold)) + .foregroundColor(isFollowing ? .nightPrimary : .nightBase) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(isFollowing ? Color.nightRaised : Color.nightPrimary) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(isFollowing ? Color.nightBorder : .clear, lineWidth: 1) + ) + } + .disabled(isLoading) + .padding(.horizontal, 48) + .animation(.spring(duration: 0.3, bounce: 0.3), value: isFollowing) + } +} + +// MARK: - Streak Badge + +struct StreakBadge: View { + let streak: Int + @State private var fireScale: CGFloat = 1.0 + + var isHot: Bool { streak >= 7 } + + var body: some View { + HStack(spacing: 6) { + Image(systemName: isHot ? "flame.fill" : "flame") + .foregroundColor(isHot ? .orange : .nightSecondary) + .scaleEffect(fireScale) + Text("\(streak) Nächte in Folge") + .font(.nightLabel(13, weight: isHot ? .semibold : .regular)) + .foregroundColor(isHot ? .nightPrimary : .nightSecondary) + } + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background( + ZStack { + Capsule().fill(Color.nightRaised) + if isHot { + Capsule().fill(Color.orange.opacity(0.06)) + Capsule().strokeBorder(Color.orange.opacity(0.2), lineWidth: 1) + } + } + ) + .onAppear { + guard isHot else { return } + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + fireScale = 1.18 + } + } + } +} + +// MARK: - Profile Stat + +struct ProfileStat: View { + let value: Int + let label: String + @State private var displayValue: Int = 0 + @State private var appeared = false + + var body: some View { + VStack(spacing: 3) { + Text("\(displayValue)") + .font(.nightTitle(18)) + .foregroundColor(.nightPrimary) + .contentTransition(.numericText(value: Double(displayValue))) + Text(label) + .font(.nightLabel(12)) + .foregroundColor(.nightSecondary) + } + .onAppear { + guard !appeared else { return } + appeared = true + withAnimation(.easeOut(duration: 0.5).delay(0.3)) { + displayValue = value + } + } + } +} + +// MARK: - Empty State + +struct EmptyProfilePosts: View { + @State private var appeared = false + + var body: some View { + VStack(spacing: 14) { + Image(systemName: "moon.zzz") + .font(.system(size: 36)) + .foregroundColor(.nightTertiary) + Text("noch keine nächte") + .font(.nightLabel(15)) + .foregroundColor(.nightSecondary) + } + .padding(.top, 60) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 12) + .onAppear { + withAnimation(.spring(duration: 0.5, bounce: 0.2).delay(0.2)) { + appeared = true + } + } + } +} diff --git a/ios/thoughts/thoughts/Views/RootView.swift b/ios/thoughts/thoughts/Views/RootView.swift new file mode 100644 index 0000000..009079f --- /dev/null +++ b/ios/thoughts/thoughts/Views/RootView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct RootView: View { + @EnvironmentObject var appState: AppState + + var body: some View { + Group { + if appState.isAuthenticated { + MainTabView() + .transition(.opacity.combined(with: .scale(scale: 0.98))) + } else { + OnboardingView() + .transition(.opacity.combined(with: .scale(scale: 1.02))) + } + } + .animation(.spring(duration: 0.5, bounce: 0.15), value: appState.isAuthenticated) + } +} diff --git a/ios/thoughts/thoughts/Views/SettingsView.swift b/ios/thoughts/thoughts/Views/SettingsView.swift new file mode 100644 index 0000000..1a1bde7 --- /dev/null +++ b/ios/thoughts/thoughts/Views/SettingsView.swift @@ -0,0 +1,346 @@ +import SwiftUI +import UserNotifications + +struct SettingsView: View { + @EnvironmentObject var appState: AppState + @Environment(\.dismiss) var dismiss + @State private var showLegal = false + @State private var showDeleteConfirm = false + @State private var showDeleteFinal = false + @State private var deletePassword = "" + @State private var isDeleting = false + @State private var deleteError: String? + @State private var notificationsEnabled = false + @State private var appeared = false + + var body: some View { + NavigationStack { + ZStack { + Color.nightBase.ignoresSafeArea() + + ScrollView { + VStack(spacing: 20) { + + // Account + SettingsSection { + if let user = appState.currentUser { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(Color.nightPurple.opacity(0.1)) + .frame(width: 52, height: 52) + AvatarView(user: user, size: 44) + } + VStack(alignment: .leading, spacing: 2) { + Text(user.displayName) + .font(.nightLabel(15, weight: .semibold)) + .foregroundColor(.nightPrimary) + Text("@\(user.username)") + .font(.nightLabel(13)) + .foregroundColor(.nightSecondary) + } + Spacer() + } + .padding(.vertical, 4) + } + } + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 12) + .animation(.spring(duration: 0.4, bounce: 0.15).delay(0.05), value: appeared) + + // Benachrichtigungen + SettingsSection(header: "benachrichtigungen") { + SettingsRow { + HStack { + SettingsIcon(icon: "bell.fill", color: .nightPurple) + VStack(alignment: .leading, spacing: 2) { + Text("nightly ping") + .font(.nightLabel(15)) + .foregroundColor(.nightPrimary) + Text("Wenn das Fenster öffnet") + .font(.nightLabel(12)) + .foregroundColor(.nightSecondary) + } + Spacer() + Toggle("", isOn: $notificationsEnabled) + .tint(.nightPurple) + .labelsHidden() + } + } + Divider().background(Color.nightBorder).padding(.leading, 52) + SettingsRow { + HStack(spacing: 8) { + Image(systemName: "info.circle") + .foregroundColor(.nightTertiary) + .font(.system(size: 13)) + Text("Push-Benachrichtigungen benötigen einen bezahlten Apple Developer Account ($99/Jahr).") + .font(.nightLabel(12)) + .foregroundColor(.nightTertiary) + .lineSpacing(3) + } + .padding(.vertical, 2) + } + } + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 12) + .animation(.spring(duration: 0.4, bounce: 0.15).delay(0.1), value: appeared) + + // Rechtliches + SettingsSection(header: "rechtliches") { + SettingsRow { + Button { + showLegal = true + } label: { + HStack { + SettingsIcon(icon: "doc.text", color: .nightSecondary) + Text("Impressum & Datenschutz") + .font(.nightLabel(15)) + .foregroundColor(.nightPrimary) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.nightTertiary) + } + } + } + Divider().background(Color.nightBorder).padding(.leading, 52) + SettingsRow { + HStack { + SettingsIcon(icon: "info.circle", color: .nightSecondary) + Text("Version") + .font(.nightLabel(15)) + .foregroundColor(.nightPrimary) + Spacer() + Text(appVersion) + .font(.nightMono(13)) + .foregroundColor(.nightTertiary) + } + } + } + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 12) + .animation(.spring(duration: 0.4, bounce: 0.15).delay(0.15), value: appeared) + + // Account-Aktionen + SettingsSection(header: "account") { + SettingsRow { + Button { + Haptics.medium() + appState.signOut() + dismiss() + } label: { + HStack { + SettingsIcon(icon: "rectangle.portrait.and.arrow.right", color: .nightSecondary) + Text("abmelden") + .font(.nightLabel(15)) + .foregroundColor(.nightPrimary) + Spacer() + } + } + } + Divider().background(Color.nightBorder).padding(.leading, 52) + SettingsRow { + Button { + Haptics.warning() + showDeleteConfirm = true + } label: { + HStack { + SettingsIcon(icon: "trash", color: .nightRed) + Text("account löschen") + .font(.nightLabel(15)) + .foregroundColor(.nightRed) + Spacer() + } + } + } + } + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 12) + .animation(.spring(duration: 0.4, bounce: 0.15).delay(0.2), value: appeared) + + Color.clear.frame(height: 40) + } + .padding(.horizontal, 16) + .padding(.top, 8) + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text("einstellungen") + .font(.nightTitle(17)) + .foregroundColor(.nightPrimary) + } + ToolbarItem(placement: .navigationBarLeading) { + Button("Fertig") { dismiss() } + .foregroundColor(.nightSecondary) + } + } + .sheet(isPresented: $showLegal) { LegalView() } + } + .preferredColorScheme(.dark) + .confirmationDialog( + "Account wirklich löschen?", + isPresented: $showDeleteConfirm, + titleVisibility: .visible + ) { + Button("Ja, Account löschen", role: .destructive) { + showDeleteFinal = true + } + } message: { + Text("Alle deine Posts, Reaktionen und Daten werden sofort und dauerhaft gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden.") + } + .sheet(isPresented: $showDeleteFinal) { + DeleteAccountSheet( + password: $deletePassword, + isDeleting: isDeleting, + error: deleteError, + onDelete: { Task { await deleteAccount() } } + ) + } + .onAppear { + checkNotificationStatus() + appeared = true + } + } + + var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + } + + func checkNotificationStatus() { + Task { + let settings = await UNUserNotificationCenter.current().notificationSettings() + notificationsEnabled = settings.authorizationStatus == .authorized + } + } + + func deleteAccount() async { + isDeleting = true + deleteError = nil + defer { isDeleting = false } + do { + try await appState.deleteAccount() + showDeleteFinal = false + dismiss() + } catch { + deleteError = error.localizedDescription + } + } +} + +// MARK: - Settings Components + +struct SettingsSection: View { + var header: String? = nil + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let header { + Text(header) + .font(.nightLabel(12, weight: .semibold)) + .foregroundColor(.nightSecondary) + .kerning(0.5) + .padding(.leading, 4) + .padding(.bottom, 8) + } + VStack(spacing: 0) { + content + } + .background(Color.nightSurface) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay( + RoundedRectangle(cornerRadius: 14) + .strokeBorder(Color.nightBorder, lineWidth: 1) + ) + } + } +} + +struct SettingsRow: View { + @ViewBuilder let content: Content + + var body: some View { + content + .padding(.horizontal, 16) + .padding(.vertical, 13) + } +} + +struct SettingsIcon: View { + let icon: String + let color: Color + + var body: some View { + Image(systemName: icon) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(color) + .frame(width: 28, height: 28) + .background(color.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 7)) + } +} + +// MARK: - Delete Account Sheet + +struct DeleteAccountSheet: View { + @Binding var password: String + let isDeleting: Bool + let error: String? + let onDelete: () -> Void + @Environment(\.dismiss) var dismiss + + var body: some View { + NavigationStack { + ZStack { + Color.nightBase.ignoresSafeArea() + VStack(spacing: 24) { + Image(systemName: "trash.circle.fill") + .font(.system(size: 52)) + .foregroundColor(.nightRed) + + VStack(spacing: 8) { + Text("Account löschen") + .font(.nightTitle(22)) + .foregroundColor(.nightPrimary) + Text("Diese Aktion löscht alle deine Daten dauerhaft. Kein Weg zurück.") + .font(.nightBody(15)) + .foregroundColor(.nightSecondary) + .multilineTextAlignment(.center) + } + + NightlyField(text: $password, placeholder: "passwort bestätigen", icon: "lock", isSecure: true) + .padding(.horizontal, 24) + + if let err = error { + Text(err).font(.nightLabel(13)).foregroundColor(.nightRed) + } + + Button { + Haptics.warning() + onDelete() + } label: { + Group { + if isDeleting { ProgressView().tint(.white) } + else { Text("endgültig löschen").font(.nightLabel(16, weight: .semibold)).foregroundColor(.white) } + } + .frame(maxWidth: .infinity).frame(height: 50) + .background(Color.nightRed) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .disabled(password.isEmpty || isDeleting) + .padding(.horizontal, 24) + } + .padding(.top, 32) + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Abbrechen") { dismiss() }.foregroundColor(.nightSecondary) + } + } + } + .preferredColorScheme(.dark) + .presentationDetents([.medium]) + } +} diff --git a/ios/thoughts/thoughts/thoughts.entitlements b/ios/thoughts/thoughts/thoughts.entitlements new file mode 100644 index 0000000..6631ffa --- /dev/null +++ b/ios/thoughts/thoughts/thoughts.entitlements @@ -0,0 +1,6 @@ + + + + + + diff --git a/ios/thoughts/thoughtsTests/thoughtsTests.swift b/ios/thoughts/thoughtsTests/thoughtsTests.swift new file mode 100644 index 0000000..fc11dc0 --- /dev/null +++ b/ios/thoughts/thoughtsTests/thoughtsTests.swift @@ -0,0 +1,19 @@ +// +// thoughtsTests.swift +// thoughtsTests +// +// Created by Dennis Konkol on 22.04.26. +// + +import Testing +@testable import thoughts + +struct thoughtsTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + // Swift Testing Documentation + // https://developer.apple.com/documentation/testing + } + +} diff --git a/ios/thoughts/thoughtsUITests/thoughtsUITests.swift b/ios/thoughts/thoughtsUITests/thoughtsUITests.swift new file mode 100644 index 0000000..4584b3a --- /dev/null +++ b/ios/thoughts/thoughtsUITests/thoughtsUITests.swift @@ -0,0 +1,43 @@ +// +// thoughtsUITests.swift +// thoughtsUITests +// +// Created by Dennis Konkol on 22.04.26. +// + +import XCTest + +final class thoughtsUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + // XCUIAutomation Documentation + // https://developer.apple.com/documentation/xcuiautomation + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/ios/thoughts/thoughtsUITests/thoughtsUITestsLaunchTests.swift b/ios/thoughts/thoughtsUITests/thoughtsUITestsLaunchTests.swift new file mode 100644 index 0000000..daca637 --- /dev/null +++ b/ios/thoughts/thoughtsUITests/thoughtsUITestsLaunchTests.swift @@ -0,0 +1,35 @@ +// +// thoughtsUITestsLaunchTests.swift +// thoughtsUITests +// +// Created by Dennis Konkol on 22.04.26. +// + +import XCTest + +final class thoughtsUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + // XCUIAutomation Documentation + // https://developer.apple.com/documentation/xcuiautomation + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +}