Initial commit: nightly iOS app + Supabase backend

iOS SwiftUI app with Supabase auth/realtime, Node.js backend,
Docker/Supabase self-hosted infrastructure, and APNs scheduler.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
denshooter
2026-04-23 23:31:38 +02:00
commit 5bc81d5b3b
80 changed files with 9958 additions and 0 deletions
+41
View File
@@ -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
+8
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
# Replace with your actual domain
api.nightly.app {
reverse_proxy api:3000 {
header_up X-Real-IP {remote_host}
}
}
+7
View File
@@ -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"]
+19
View File
@@ -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"
}
}
+89
View File
@@ -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;
+31
View File
@@ -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'))
+14
View File
@@ -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' })
}
}
+32
View File
@@ -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)
}
+60
View File
@@ -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: 320 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
+110
View File
@@ -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
+191
View File
@@ -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
+82
View File
@@ -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 = 25 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
+96
View File
@@ -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`)
}
+66
View File
@@ -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
+15
View File
@@ -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
+19
View File
@@ -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
}
@@ -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
+237
View File
@@ -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);
+8
View File
@@ -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"]
+13
View File
@@ -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"
}
}
+216
View File
@@ -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()
+63
View File
@@ -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"
+124
View File
@@ -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() }
}
}
}
+79
View File
@@ -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)
}
}
+107
View File
@@ -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()
)
]
}
+25
View File
@@ -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
)
}
+67
View File
@@ -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")
}
+35
View File
@@ -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
}()
}
+127
View File
@@ -0,0 +1,127 @@
import Foundation
import UIKit
actor APIService {
static let shared = APIService()
// Change to your server URL
private let baseURL = URL(string: "https://api.nightly.app/")!
private var authToken: String? { KeychainService.shared.getToken() }
// MARK: - Auth
func login(username: String, password: String) async throws {
let r: AuthResponse = try await post("auth/login", body: [
"username": username, "password": password
])
KeychainService.shared.saveToken(r.token)
}
func register(username: String, password: String, displayName: String) async throws {
let r: AuthResponse = try await post("auth/register", body: [
"username": username, "password": password, "displayName": displayName
])
KeychainService.shared.saveToken(r.token)
}
func getCurrentUser() async throws -> User {
try await get("users/me")
}
// MARK: - Posts
func getFeed() async throws -> [Post] {
try await get("posts/feed")
}
func createPost(content: String, mood: Mood, isAnonymous: Bool) async throws {
let _: EmptyResponse = try await post("posts", body: [
"content": content,
"mood": mood.rawValue,
"isAnonymous": isAnonymous
])
}
func resonate(postId: String) async throws {
let _: EmptyResponse = try await post("posts/\(postId)/resonate", body: [:])
}
func unresoante(postId: String) async throws {
let _: EmptyResponse = try await delete("posts/\(postId)/resonate")
}
func sendWhisper(toUserId: String, content: String) async throws {
let _: EmptyResponse = try await post("users/\(toUserId)/whisper", body: ["content": content])
}
// MARK: - Users
func getUserPosts(userId: String) async throws -> [Post] {
try await get("users/\(userId)/posts")
}
func getUserStreak(userId: String) async throws -> Int {
let r: StreakResponse = try await get("users/\(userId)/streak")
return r.streak
}
func registerPushToken(_ token: String) async {
_ = try? await post("users/me/push-token", body: ["token": token]) as EmptyResponse
}
// MARK: - HTTP
private func get<T: Decodable>(_ path: String) async throws -> T {
try await perform(makeRequest("GET", path: path))
}
@discardableResult
private func post<T: Decodable>(_ path: String, body: [String: Any]) async throws -> T {
var req = makeRequest("POST", path: path)
req.httpBody = try JSONSerialization.data(withJSONObject: body)
return try await perform(req)
}
@discardableResult
private func delete<T: Decodable>(_ path: String) async throws -> T {
try await perform(makeRequest("DELETE", path: path))
}
private func makeRequest(_ method: String, path: String) -> URLRequest {
var req = URLRequest(url: baseURL.appendingPathComponent(path))
req.httpMethod = method
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let t = authToken { req.setValue("Bearer \(t)", forHTTPHeaderField: "Authorization") }
return req
}
private func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
guard (200...299).contains(http.statusCode) else {
let msg = (try? JSONDecoder().decode(APIErrorBody.self, from: data))?.message
?? HTTPURLResponse.localizedString(forStatusCode: http.statusCode)
throw APIError.serverError(msg)
}
let dec = JSONDecoder()
dec.dateDecodingStrategy = .iso8601
return try dec.decode(T.self, from: data)
}
}
private struct AuthResponse: Decodable { let token: String }
private struct StreakResponse: Decodable { let streak: Int }
private struct APIErrorBody: Decodable { let message: String }
struct EmptyResponse: Decodable {}
enum APIError: LocalizedError {
case invalidResponse
case serverError(String)
var errorDescription: String? {
switch self {
case .invalidResponse: return "Ungültige Serverantwort"
case .serverError(let m): return m
}
}
}
@@ -0,0 +1,43 @@
import Foundation
import Security
final class KeychainService {
static let shared = KeychainService()
private let service = "app.nightly"
private let account = "authToken"
func saveToken(_ token: String) {
let data = Data(token.utf8)
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecValueData: data
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
func getToken() -> String? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
func deleteToken() {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account
]
SecItemDelete(query as CFDictionary)
}
}
@@ -0,0 +1,16 @@
import UserNotifications
import UIKit
final class NotificationService {
static let shared = NotificationService()
func requestPermission() async -> Bool {
(try? await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
}
@MainActor
func registerForRemoteNotifications() {
UIApplication.shared.registerForRemoteNotifications()
}
}
@@ -0,0 +1,86 @@
import Foundation
import Supabase
/// Verwaltet die Echtzeit-Verbindung für "Gerade Jetzt".
/// Neue Posts erscheinen sofort ohne Polling.
@MainActor
class RealtimeService: ObservableObject {
@Published var newPostsCount = 0
private var channel: RealtimeChannelV2?
private var onNewPost: ((Post) -> Void)?
func startListening(onNewPost: @escaping (Post) -> Void) async {
self.onNewPost = onNewPost
guard channel == nil else { return }
let ch = await supabase.channel("public:posts")
// Neue Posts in Echtzeit empfangen
let stream = await ch.postgresChange(
InsertAction.self,
schema: "public",
table: "posts"
)
await ch.subscribe()
self.channel = ch
// Stream im Hintergrund konsumieren
Task { [weak self] in
for await action in stream {
await self?.handleInsert(action)
}
}
}
func stopListening() async {
if let ch = channel {
await supabase.removeChannel(ch)
channel = nil
}
}
private func handleInsert(_ action: InsertAction) {
// Den neuen Post aus dem Record dekodieren
guard
let id = action.record["id"]?.stringValue,
let content = action.record["content"]?.stringValue,
let createdAt = action.record["created_at"]?.stringValue
.flatMap({ ISO8601DateFormatter().date(from: $0) }),
let userId = action.record["user_id"]?.stringValue,
let isAnon = action.record["is_anonymous"]?.boolValue
else { return }
let moodString = action.record["mood"]?.stringValue
let mood = moodString.flatMap(Mood.init(rawValue:))
let post = Post(
id: id,
author: User.anonymousPlaceholder, // Profil wird lazily nachgeladen
content: content,
mood: mood,
createdAt: createdAt,
resonanceCount: 0,
hasResonated: false,
commentCount: 0,
isAnonymous: isAnon,
nightOf: createdAt
)
newPostsCount += 1
onNewPost?(post)
}
}
// MARK: - User placeholder für Realtime (Profil wird nachgeladen)
extension User {
static let anonymousPlaceholder = User(
id: "anonymous",
username: "anonym",
displayName: "anonym",
bio: nil, avatarURL: nil,
followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false
)
}
@@ -0,0 +1,379 @@
import Foundation
import Supabase
// MARK: - Supabase Client (Singleton)
let supabase = SupabaseClient(
supabaseURL: Config.supabaseURL,
supabaseKey: Config.supabaseAnonKey,
options: SupabaseClientOptions(
db: .init(
encoder: {
let e = JSONEncoder()
e.keyEncodingStrategy = .convertToSnakeCase
e.dateEncodingStrategy = .iso8601
return e
}(),
decoder: {
let d = JSONDecoder()
d.keyDecodingStrategy = .convertFromSnakeCase
d.dateDecodingStrategy = .iso8601
return d
}()
)
)
)
// MARK: - Auth
extension SupabaseClient {
func signUp(email: String, password: String, username: String, displayName: String) async throws {
try await self.auth.signUp(
email: email,
password: password,
data: [
"username": .string(username.lowercased()),
"display_name": .string(displayName)
]
)
}
func signIn(email: String, password: String) async throws {
try await self.auth.signIn(email: email, password: password)
}
/// Login mit Username: holt zuerst die E-Mail, dann normaler Sign-In
func signIn(username: String, password: String) async throws {
let email: String? = try await self
.rpc("get_email_by_username", params: ["p_username": username])
.execute()
.value
guard let email else { throw AuthError.usernameNotFound }
try await self.auth.signIn(email: email, password: password)
}
func signOut() async throws {
try await self.auth.signOut()
}
/// Account vollständig löschen (DSGVO löscht alles über DB-Funktion)
func deleteAccount() async throws {
try await self.rpc("delete_my_account").execute()
}
var currentUserId: UUID? {
try? self.auth.session.user.id
}
}
// MARK: - Profil
extension SupabaseClient {
func getMyProfile() async throws -> Profile {
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
return try await self
.from("profiles")
.select()
.eq("id", value: uid)
.single()
.execute()
.value
}
func getProfile(userId: UUID) async throws -> Profile {
try await self
.from("profiles")
.select()
.eq("id", value: userId)
.single()
.execute()
.value
}
func updateProfile(displayName: String? = nil, bio: String? = nil) async throws {
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
var update: [String: String] = [:]
if let n = displayName { update["display_name"] = n }
if let b = bio { update["bio"] = b }
guard !update.isEmpty else { return }
try await self.from("profiles").update(update).eq("id", value: uid).execute()
}
func savePushToken(_ token: String) async throws {
guard let uid = currentUserId else { return }
try await self.from("profiles")
.update(["push_token": token])
.eq("id", value: uid)
.execute()
}
func removePushToken() async throws {
guard let uid = currentUserId else { return }
try await self.from("profiles")
.update(["push_token": nil as String?])
.eq("id", value: uid)
.execute()
}
}
// MARK: - Posts
extension SupabaseClient {
/// Feed: Posts der letzten 14h von gefollowten Usern + eigene
func getFeed() async throws -> [Post] {
guard let uid = currentUserId else { return [] }
// Erst die gefolgten User-IDs holen
// Supabase gibt Objekte zurück [{following_id:"uuid"}], kein [String]
struct FollowRow: Decodable { let followingId: String }
let followRows: [FollowRow] = try await self
.from("follows")
.select("following_id")
.eq("follower_id", value: uid)
.execute()
.value
let allIds = followRows.map(\.followingId) + [uid.uuidString]
let rows: [FeedPostRow] = try await self
.from("feed_posts")
.select()
.in("author_id", values: allIds)
.order("created_at", ascending: false)
.limit(150)
.execute()
.value
// Eigene Resonances holen (RLS filtert, SDK gibt nur eigene zurück)
let myResonances: [ResonanceRow] = (try? await self
.from("resonances")
.select("post_id")
.eq("user_id", value: uid)
.execute()
.value) ?? []
let mySet = Set(myResonances.map(\.postId))
return rows.map { row in row.toPost(hasResonated: mySet.contains(row.id)) }
}
/// Persönliches Tagebuch: alle eigenen Posts, auch gelöschte (soft)
func getDiary() async throws -> [Post] {
guard let uid = currentUserId else { return [] }
let rows: [FeedPostRow] = try await self
.from("posts")
.select("id, content, mood, is_anonymous, created_at, resonance_count:resonances(count)")
.eq("user_id", value: uid)
.is("deleted_at", value: nil)
.order("created_at", ascending: false)
.limit(365)
.execute()
.value
return rows.map { $0.toPost(hasResonated: false) }
}
func getUserPosts(userId: UUID) async throws -> [Post] {
let rows: [FeedPostRow] = try await self
.from("feed_posts")
.select()
.eq("author_id", value: userId)
.order("created_at", ascending: false)
.limit(50)
.execute()
.value
return rows.map { $0.toPost(hasResonated: false) }
}
func createPost(content: String, mood: Mood, isAnonymous: Bool) async throws {
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
try await self.from("posts").insert([
"user_id": uid.uuidString,
"content": content,
"mood": mood.rawValue,
"is_anonymous": isAnonymous
]).execute()
}
func softDeletePost(id: String) async throws {
try await self.from("posts")
.update(["deleted_at": ISO8601DateFormatter().string(from: Date())])
.eq("id", value: id)
.execute()
}
}
// MARK: - Resonances
extension SupabaseClient {
func toggleResonance(postId: String, currentlyActive: Bool) async throws {
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
if currentlyActive {
try await self.from("resonances")
.delete()
.eq("post_id", value: postId)
.eq("user_id", value: uid)
.execute()
} else {
try await self.from("resonances")
.insert(["post_id": postId, "user_id": uid.uuidString])
.execute()
}
}
}
// MARK: - Follows
extension SupabaseClient {
func follow(userId: UUID) async throws {
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
try await self.from("follows")
.insert(["follower_id": uid.uuidString, "following_id": userId.uuidString])
.execute()
}
func unfollow(userId: UUID) async throws {
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
try await self.from("follows")
.delete()
.eq("follower_id", value: uid)
.eq("following_id", value: userId)
.execute()
}
func getStreak(userId: UUID) async throws -> Int {
// Nächte mit Posts berechnet in SQL
let rows: [[String: Int]] = (try? await self
.rpc("get_streak", params: ["p_user_id": userId.uuidString])
.execute()
.value) ?? []
return rows.first?["streak"] ?? 0
}
}
// MARK: - Reports
extension SupabaseClient {
func reportPost(postId: String, reason: String, details: String?) async throws {
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
try await self.from("reports").insert([
"post_id": postId,
"reporter_id": uid.uuidString,
"reason": reason,
"details": details as Any
]).execute()
}
}
// MARK: - Whispers
extension SupabaseClient {
func sendWhisper(toUserId: UUID, content: String, postId: String?) async throws {
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
try await self.from("whispers").insert([
"from_user_id": uid.uuidString,
"to_user_id": toUserId.uuidString,
"content": content,
"post_id": postId as Any
]).execute()
}
func getMyWhispers() async throws -> [Whisper] {
guard let uid = currentUserId else { return [] }
return try await self
.from("whispers")
.select("*, from_profile:profiles!from_user_id(username, display_name, avatar_url)")
.eq("to_user_id", value: uid)
.order("created_at", ascending: false)
.limit(50)
.execute()
.value
}
func markWhisperRead(id: UUID) async throws {
try await self.from("whispers")
.update(["read_at": ISO8601DateFormatter().string(from: Date())])
.eq("id", value: id)
.execute()
}
}
// MARK: - Errors
enum AuthError: LocalizedError {
case notAuthenticated
case usernameNotFound
var errorDescription: String? {
switch self {
case .notAuthenticated: return "Nicht angemeldet"
case .usernameNotFound: return "Benutzername nicht gefunden"
}
}
}
// MARK: - Row types (Supabase responses)
struct FeedPostRow: Decodable {
let id: String
let content: String
let mood: String?
let isAnonymous: Bool
let createdAt: Date
let resonanceCount: Int
// Autor (nil bei anonymen Posts die nicht von mir sind)
let authorId: String?
let authorUsername: String?
let authorDisplayName: String?
let authorAvatarUrl: String?
func toPost(hasResonated: Bool) -> Post {
let author: User? = authorId.map {
User(
id: $0,
username: authorUsername ?? "?",
displayName: authorDisplayName ?? "?",
bio: nil, avatarURL: authorAvatarUrl.flatMap(URL.init),
followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false
)
}
return Post(
id: id,
author: author ?? User.anonymousPlaceholder,
content: content,
mood: mood.flatMap(Mood.init(rawValue:)),
createdAt: createdAt,
resonanceCount: resonanceCount,
hasResonated: hasResonated,
commentCount: 0,
isAnonymous: isAnonymous,
nightOf: createdAt
)
}
}
struct ResonanceRow: Decodable { let postId: String }
struct Profile: Decodable {
let id: UUID
let username: String
let displayName: String
let bio: String?
let avatarUrl: String?
let isPro: Bool
let isAdmin: Bool
let createdAt: Date
}
struct Whisper: Identifiable, Decodable {
let id: UUID
let fromUserId: UUID
let content: String
let readAt: Date?
let createdAt: Date
}
@@ -0,0 +1,40 @@
import Foundation
@MainActor
class FeedViewModel: ObservableObject {
@Published var posts: [Post] = []
@Published var isLoading = false
func load() async {
isLoading = true
defer { isLoading = false }
do {
posts = try await supabase.getFeed()
} catch {
#if DEBUG
posts = Post.previews
#endif
}
}
func resonate(_ post: Post) async {
guard let idx = posts.firstIndex(where: { $0.id == post.id }) else { return }
let wasActive = posts[idx].hasResonated
posts[idx].hasResonated = !wasActive
posts[idx].resonanceCount += wasActive ? -1 : 1
do {
try await supabase.toggleResonance(postId: post.id, currentlyActive: wasActive)
} catch {
posts[idx].hasResonated = wasActive
posts[idx].resonanceCount += wasActive ? 1 : -1
}
}
/// Neuen Post vom Realtime-Service in den Feed einfügen
func prepend(_ post: Post) {
guard !posts.contains(where: { $0.id == post.id }) else { return }
posts.insert(post, at: 0)
}
}
@@ -0,0 +1,33 @@
import Foundation
@MainActor
class ProfileViewModel: ObservableObject {
@Published var posts: [Post] = []
@Published var streak: Int = 0
@Published var isLoading = false
let userId: UUID
init(userId: UUID) {
self.userId = userId
}
convenience init(userIdString: String) {
self.init(userId: UUID(uuidString: userIdString) ?? UUID())
}
func load() async {
isLoading = true
defer { isLoading = false }
do {
async let postsTask = supabase.getUserPosts(userId: userId)
async let streakTask = supabase.getStreak(userId: userId)
(posts, streak) = try await (postsTask, streakTask)
} catch {
#if DEBUG
posts = Post.previews
streak = 4
#endif
}
}
}
+320
View File
@@ -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"
}
}
+287
View File
@@ -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 25 Uhr zur selben Nacht gehört)
let grouped = Dictionary(grouping: posts) { post -> String in
let nightDate = post.createdAt.addingTimeInterval(-2 * 3600)
let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateFormat = "EEEE, d. MMMM yyyy"
return f.string(from: nightDate)
}
groupedPosts = grouped
.sorted { $0.key > $1.key }
.map { NightGroup(nightLabel: $0.key, posts: $0.value) }
// Memory: Post von vor genau einem Jahr
let oneYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: Date())!
yearAgoPost = posts.first { post in
Calendar.current.isDate(post.createdAt, equalTo: oneYearAgo, toGranularity: .day)
}
} catch {
#if DEBUG
groupedPosts = [NightGroup(nightLabel: "gestern", posts: Post.previews)]
#endif
}
}
func deletePost(_ post: Post) async {
try? await supabase.softDeletePost(id: post.id)
await load()
}
}
+385
View File
@@ -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)
}
}
+232
View File
@@ -0,0 +1,232 @@
import SwiftUI
/// Impressum + Datenschutzerklärung
/// Muss vor dem Launch von einem Anwalt geprüft/ergänzt werden!
/// Kosten: ca. 300500 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. 300500€.
"""
}
LegalParagraph(title: "Verantwortlicher") {
"[DEIN NAME], [ADRESSE], [E-MAIL]"
}
LegalParagraph(title: "Welche Daten wir speichern") {
"""
• E-Mail-Adresse (für Account & Passwort-Reset)
• Benutzername und Anzeigename
• Posts, Reaktionen, Kommentare (Inhalte die du selbst erstellst)
• Push-Token (für Benachrichtigungen, optional)
• IP-Adresse in Server-Logs (max. 14 Tage)
"""
}
LegalParagraph(title: "Wofür wir Daten verwenden") {
"""
• Betrieb des Dienstes (Authentifizierung, Feed, Benachrichtigungen)
• Moderation (Meldungen von Inhalten)
• Keine Weitergabe an Dritte außer für den Betrieb notwendige Dienste
"""
}
LegalParagraph(title: "Serverstandort") {
"Alle Daten werden auf Servern in der EU gespeichert."
}
LegalParagraph(title: "Deine Rechte (DSGVO)") {
"""
• Auskunft über gespeicherte Daten: legal@xxx.dk0.dev
• Berichtigung falscher Daten
• Löschung: Account in den Einstellungen löschen — entfernt alle deine Daten sofort
• Datenübertragbarkeit: auf Anfrage per E-Mail
• Widerspruch gegen Verarbeitung: legal@xxx.dk0.dev
• Beschwerde bei der Datenschutzbehörde
"""
}
LegalParagraph(title: "Datenlöschung") {
"""
Posts werden 14 Stunden nach Erstellung aus dem öffentlichen Feed entfernt. \
Dein persönliches Tagebuch behältst du so lange du möchtest. \
Account-Löschung entfernt alle Daten dauerhaft und unwiderruflich.
"""
}
LegalParagraph(title: "Cookies / Tracking") {
"Wir verwenden keine Cookies, keine Tracker, keine Werbenetze."
}
}
}
}
// MARK: - Nutzungsbedingungen
struct NutzungsbedingungenContent: View {
var body: some View {
LegalSection(title: "Nutzungsbedingungen") {
LegalParagraph(title: "⚠️ Entwurf") {
"Diese Nutzungsbedingungen sind ein Entwurf und müssen vor dem Launch von einem Anwalt geprüft werden."
}
LegalParagraph(title: "Nutzung") {
"""
nightly ist ein Dienst für Personen ab 17 Jahren. \
Du bist für die Inhalte die du postest selbst verantwortlich.
"""
}
LegalParagraph(title: "Verbotene Inhalte") {
"""
Folgende Inhalte sind verboten:
• Hassrede, Diskriminierung, Bedrohung
• Belästigung oder Mobbing
• Illegale Inhalte jeglicher Art
• Spam oder kommerzielle Werbung
• Inhalte die andere Personen ohne deren Zustimmung zeigen
"""
}
LegalParagraph(title: "Moderation") {
"""
Gemeldete Inhalte werden geprüft und können ohne Vorankündigung entfernt werden. \
Bei schwerwiegenden Verstößen behalten wir uns die Sperrung des Accounts vor.
"""
}
LegalParagraph(title: "Haftungsausschluss") {
"""
Wir übernehmen keine Haftung für nutzergenerierte Inhalte. \
Der Dienst wird ohne Gewähr für Verfügbarkeit bereitgestellt.
"""
}
}
}
}
// MARK: - Reusable components
struct LegalSection<Content: View>: View {
let title: String
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text(title)
.font(.nightTitle(22))
.foregroundColor(.nightPrimary)
.padding(.bottom, 4)
content
}
}
}
struct LegalParagraph: View {
let title: String
let body: String
init(title: String, _ body: () -> String) {
self.title = title
self.body = body()
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.nightLabel(14, weight: .semibold))
.foregroundColor(.nightPrimary)
Text(body)
.font(.nightBody(14))
.foregroundColor(.nightSecondary)
.lineSpacing(4)
.fixedSize(horizontal: false, vertical: true)
}
}
}
+199
View File
@@ -0,0 +1,199 @@
import SwiftUI
struct MainTabView: View {
@EnvironmentObject var appState: AppState
@StateObject private var realtime = RealtimeService()
@State private var selectedTab = 0
@State private var showCompose = false
@State private var showSettings = false
var body: some View {
ZStack(alignment: .bottom) {
Color.nightBase.ignoresSafeArea()
// Content
TabContent(
selectedTab: selectedTab,
realtime: realtime
)
.environmentObject(appState)
// Floating Tab Bar
FloatingTabBar(
selectedTab: $selectedTab,
windowState: appState.windowState,
onCompose: { showCompose = true },
onSettings: { showSettings = true }
)
}
.ignoresSafeArea(edges: .bottom)
.sheet(isPresented: $showCompose) {
ComposeView().environmentObject(appState)
}
.sheet(isPresented: $showSettings) {
SettingsView().environmentObject(appState)
}
.onDisappear {
Task { await realtime.stopListening() }
}
}
}
// MARK: - Tab Content
private struct TabContent: View {
let selectedTab: Int
@ObservedObject var realtime: RealtimeService
@EnvironmentObject var appState: AppState
var body: some View {
ZStack {
FeedView(realtime: realtime)
.environmentObject(appState)
.opacity(selectedTab == 0 ? 1 : 0)
.allowsHitTesting(selectedTab == 0)
DiaryView()
.environmentObject(appState)
.opacity(selectedTab == 1 ? 1 : 0)
.allowsHitTesting(selectedTab == 1)
ProfileView(
user: appState.currentUser ?? .preview,
isCurrentUser: true
)
.environmentObject(appState)
.opacity(selectedTab == 2 ? 1 : 0)
.allowsHitTesting(selectedTab == 2)
}
}
}
// MARK: - Floating Tab Bar
struct FloatingTabBar: View {
@Binding var selectedTab: Int
let windowState: AppState.WindowState
let onCompose: () -> Void
let onSettings: () -> Void
var body: some View {
HStack(spacing: 0) {
// Feed
TabIcon(icon: "moon.stars", activeIcon: "moon.stars.fill", isSelected: selectedTab == 0) {
selectedTab = 0
}
Spacer()
// Diary
TabIcon(icon: "book.closed", activeIcon: "book.closed.fill", isSelected: selectedTab == 1) {
selectedTab = 1
}
Spacer()
// Center: Compose
ComposeTabButton(windowState: windowState, onTap: onCompose)
Spacer()
// Profile
TabIcon(icon: "person", activeIcon: "person.fill", isSelected: selectedTab == 2) {
selectedTab = 2
}
Spacer()
// Settings
TabIcon(icon: "gearshape", activeIcon: "gearshape.fill", isSelected: false) {
onSettings()
}
}
.padding(.horizontal, 20)
.padding(.vertical, 10)
.padding(.bottom, 18)
.background(
Rectangle()
.fill(.ultraThinMaterial.opacity(0.8))
.background(Color.nightBase.opacity(0.85))
.ignoresSafeArea()
)
.overlay(
Rectangle().fill(Color.nightBorder).frame(height: 1),
alignment: .top
)
}
}
struct TabIcon: View {
let icon: String
let activeIcon: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: isSelected ? activeIcon : icon)
.font(.system(size: 21))
.foregroundColor(isSelected ? .nightPrimary : .nightSecondary)
.frame(width: 44, height: 44)
}
}
}
struct ComposeTabButton: View {
let windowState: AppState.WindowState
let onTap: () -> Void
@State private var glow = false
var body: some View {
Button {
guard windowState == .open else { return }
onTap()
} label: {
ZStack {
if windowState == .open {
Circle()
.fill(Color.nightPurple.opacity(0.18))
.frame(width: 62, height: 62)
.scaleEffect(glow ? 1.15 : 1.0)
.animation(.easeInOut(duration: 2.2).repeatForever(autoreverses: true), value: glow)
}
Circle()
.fill(buttonFill)
.frame(width: 50, height: 50)
Image(systemName: buttonIcon)
.font(.system(size: 19, weight: .semibold))
.foregroundColor(.white)
}
}
.onAppear { glow = true }
.animation(.easeInOut(duration: 0.4), value: windowState)
}
var buttonFill: AnyShapeStyle {
switch windowState {
case .open:
return AnyShapeStyle(LinearGradient(
colors: [Color(hex: "8B5CF6"), Color(hex: "6D28D9")],
startPoint: .topLeading, endPoint: .bottomTrailing
))
case .posted:
return AnyShapeStyle(LinearGradient(
colors: [Color(hex: "059669"), Color(hex: "047857")],
startPoint: .top, endPoint: .bottom
))
default:
return AnyShapeStyle(Color.nightRaised)
}
}
var buttonIcon: String {
switch windowState {
case .open: return "plus"
case .posted: return "checkmark"
default: return "moon.zzz"
}
}
}
@@ -0,0 +1,306 @@
import SwiftUI
struct OnboardingView: View {
@EnvironmentObject var appState: AppState
@State private var phase: Phase = .welcome
@State private var isLogin = false
enum Phase { case welcome, auth }
var body: some View {
ZStack {
Color.nightBase.ignoresSafeArea()
StarField()
VStack(spacing: 0) {
Spacer()
switch phase {
case .welcome:
WelcomeScreen()
.transition(.opacity)
Spacer()
WelcomeActions(
onStart: {
isLogin = false
withAnimation(.spring(duration: 0.4)) { phase = .auth }
},
onLogin: {
isLogin = true
withAnimation(.spring(duration: 0.4)) { phase = .auth }
}
)
case .auth:
AuthScreen(isLogin: $isLogin)
.environmentObject(appState)
.transition(.move(edge: .trailing).combined(with: .opacity))
Spacer()
}
}
}
.preferredColorScheme(.dark)
}
}
// MARK: - Welcome
struct WelcomeScreen: View {
@State private var appeared = false
var body: some View {
VStack(spacing: 28) {
ZStack {
ForEach([130, 100, 70], id: \.self) { size in
Circle()
.fill(Color.nightPurple.opacity(size == 70 ? 0 : (size == 100 ? 0.08 : 0.04)))
.frame(width: CGFloat(size), height: CGFloat(size))
}
Text("")
.font(.system(size: 52))
.foregroundColor(.nightPurpleSoft)
}
.scaleEffect(appeared ? 1 : 0.75)
.opacity(appeared ? 1 : 0)
VStack(spacing: 12) {
Text("nightly")
.font(.system(size: 44, weight: .bold, design: .rounded))
.foregroundColor(.nightPrimary)
VStack(spacing: 5) {
Text("Zwischen 2 und 5 Uhr.")
Text("Kein Filter. Keine Maske.")
Text("Nur echte Gedanken.")
}
.font(.nightBody(17))
.foregroundColor(.nightSecondary)
.multilineTextAlignment(.center)
.lineSpacing(3)
}
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 10)
}
.onAppear {
withAnimation(.spring(duration: 0.8, bounce: 0.25).delay(0.1)) { appeared = true }
}
}
}
struct WelcomeActions: View {
let onStart: () -> Void
let onLogin: () -> Void
var body: some View {
VStack(spacing: 12) {
Button("loslegen", action: onStart).buttonStyle(NightlyPrimaryButton())
Button("ich hab schon einen account", action: onLogin)
.font(.nightLabel(14))
.foregroundColor(.nightSecondary)
}
.padding(.horizontal, 24)
.padding(.bottom, 52)
}
}
// MARK: - Auth Screen
struct AuthScreen: View {
@EnvironmentObject var appState: AppState
@Binding var isLogin: Bool
// Registrierung
@State private var username = ""
@State private var displayName = ""
@State private var email = ""
@State private var password = ""
@State private var isLoading = false
@State private var error: String?
var body: some View {
VStack(spacing: 22) {
Text(isLogin ? "willkommen zurück" : "mitmachen")
.font(.nightTitle(28))
.foregroundColor(.nightPrimary)
VStack(spacing: 10) {
if !isLogin {
NightlyField(text: $displayName, placeholder: "anzeigename", icon: "person")
NightlyField(text: $username, placeholder: "benutzername", icon: "at")
.textInputAutocapitalization(.never).autocorrectionDisabled()
}
NightlyField(text: $email, placeholder: "e-mail", icon: "envelope")
.textInputAutocapitalization(.never)
.keyboardType(.emailAddress)
.autocorrectionDisabled()
NightlyField(text: $password, placeholder: "passwort", icon: "lock", isSecure: true)
}
.padding(.horizontal, 24)
if let err = error {
Text(err)
.font(.nightLabel(13))
.foregroundColor(.nightRed)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
Button(isLogin ? "einloggen" : "account erstellen") {
Task { await submit() }
}
.buttonStyle(NightlyPrimaryButton(isLoading: isLoading))
.disabled(isLoading)
.padding(.horizontal, 24)
Button(isLogin ? "noch kein account?" : "schon dabei?") {
withAnimation { isLogin.toggle() }
}
.font(.nightLabel(14))
.foregroundColor(.nightSecondary)
// Rechtliches
LegalNotice()
}
}
func submit() async {
guard !email.isEmpty && !password.isEmpty else {
error = "Bitte alle Felder ausfüllen."
return
}
isLoading = true
error = nil
defer { isLoading = false }
do {
if isLogin {
try await appState.signIn(email: email, password: password)
} else {
guard !username.isEmpty && !displayName.isEmpty else {
error = "Bitte alle Felder ausfüllen."
return
}
guard username.count >= 3 else {
error = "Benutzername muss mindestens 3 Zeichen haben."
return
}
guard password.count >= 8 else {
error = "Passwort muss mindestens 8 Zeichen haben."
return
}
try await appState.signUp(
email: email,
password: password,
username: username.lowercased(),
displayName: displayName
)
}
} catch {
self.error = error.localizedDescription
}
}
}
struct LegalNotice: View {
@State private var showLegal = false
var body: some View {
VStack(spacing: 4) {
Text("Mit der Registrierung stimmst du zu:")
.font(.nightLabel(11))
.foregroundColor(.nightTertiary)
HStack(spacing: 4) {
Button("Nutzungsbedingungen") { showLegal = true }
Text("·")
Button("Datenschutzerklärung") { showLegal = true }
}
.font(.nightLabel(11, weight: .medium))
.foregroundColor(.nightSecondary)
}
.multilineTextAlignment(.center)
.sheet(isPresented: $showLegal) {
LegalView()
}
}
}
// MARK: - Reusable components
struct NightlyField: View {
@Binding var text: String
let placeholder: String
let icon: String
var isSecure = false
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 15))
.foregroundColor(.nightSecondary)
.frame(width: 18)
Group {
if isSecure { SecureField(placeholder, text: $text) }
else { TextField(placeholder, text: $text) }
}
.font(.nightBody(16))
.foregroundColor(.nightPrimary)
}
.padding(16)
.background(Color.nightSurface)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Color.nightBorder, lineWidth: 1))
.tint(.nightPurpleSoft)
}
}
struct NightlyPrimaryButton: ButtonStyle {
var isLoading = false
func makeBody(configuration: Configuration) -> some View {
Group {
if isLoading { ProgressView().tint(.nightBase).frame(maxWidth: .infinity, maxHeight: 52) }
else {
configuration.label
.font(.nightLabel(17, weight: .semibold))
.foregroundColor(.nightBase)
.frame(maxWidth: .infinity).frame(height: 52)
}
}
.background(Color.nightPrimary.opacity(configuration.isPressed ? 0.85 : 1))
.clipShape(RoundedRectangle(cornerRadius: 14))
.scaleEffect(configuration.isPressed ? 0.98 : 1)
.animation(.spring(duration: 0.2), value: configuration.isPressed)
}
}
struct StarField: View {
struct Star: Identifiable {
let id: Int; let x, y, size, opacity: CGFloat
}
private let stars: [Star] = (0..<120).map {
Star(id: $0,
x: .random(in: 0...1),
y: .random(in: 0...1),
size: .random(in: 1...2.5),
opacity: .random(in: 0.07...0.3))
}
@State private var twinkle = false
var body: some View {
GeometryReader { geo in
ForEach(stars) { s in
Circle().fill(Color.white)
.frame(width: s.size, height: s.size)
.opacity(twinkle && s.id % 5 == 0 ? s.opacity * 0.3 : s.opacity)
.position(x: s.x * geo.size.width, y: s.y * geo.size.height)
}
}
.ignoresSafeArea()
.onAppear {
withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) { twinkle = true }
}
}
}
+291
View File
@@ -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
}
}
}
+214
View File
@@ -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)
}
}
+16
View File
@@ -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)
}
}
+243
View File
@@ -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])
}
}
@@ -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 = "<group>";
};
95576B5A2F98D4200029BE54 /* thoughtsTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = thoughtsTests;
sourceTree = "<group>";
};
95576B642F98D4200029BE54 /* thoughtsUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = thoughtsUITests;
sourceTree = "<group>";
};
/* 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 = "<group>";
};
95576B472F98D41F0029BE54 /* Products */ = {
isa = PBXGroup;
children = (
95576B462F98D41F0029BE54 /* thoughts.app */,
95576B572F98D4200029BE54 /* thoughtsTests.xctest */,
95576B612F98D4200029BE54 /* thoughtsUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
95C8F8992F9AC1BB00CA5386 /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
/* 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 */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
@@ -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
}
+135
View File
@@ -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() }
}
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,79 @@
import SwiftUI
// MARK: - Design Tokens
extension Color {
// Backgrounds kein reines Schwarz, sondern Mitternachtsblau
static let nightBase = Color(hex: "080810") // Haupt-Hintergrund
static let nightSurface = Color(hex: "0E0E1C") // Karten, Sheets
static let nightRaised = Color(hex: "151528") // Elevated surfaces
static let nightBorder = Color(white: 1, opacity: 0.06)
// Text
static let nightPrimary = Color(hex: "EEEEF8")
static let nightSecondary = Color(hex: "64647A")
static let nightTertiary = Color(hex: "3A3A52")
// Akzente
static let nightPurple = Color(hex: "7B4FE8")
static let nightPurpleSoft = Color(hex: "9B77F0")
static let nightGreen = Color(hex: "34D399")
static let nightRed = Color(hex: "F27474")
// Hex initializer
init(hex: String) {
let h = hex.trimmingCharacters(in: .alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: h).scanHexInt64(&int)
let a, r, g, b: UInt64
switch h.count {
case 6: (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:(a, r, g, b) = (255, 255, 255, 255)
}
self.init(.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255)
}
}
// MARK: - Mood (passt hier semantisch besser rein als in Post.swift)
extension Mood {
var color: Color {
switch self {
case .still: return Color(hex: "4A9EFF")
case .unruhig: return Color(hex: "FF8C42")
case .melancholisch: return Color(hex: "A855F7")
case .aufgedreht: return Color(hex: "10D08A")
}
}
var label: String { rawValue }
var emoji: String {
switch self {
case .still: return ""
case .unruhig: return ""
case .melancholisch: return ""
case .aufgedreht: return ""
}
}
}
// MARK: - Typography helpers
extension Font {
static func nightTitle(_ size: CGFloat) -> Font {
.system(size: size, weight: .bold, design: .rounded)
}
static func nightBody(_ size: CGFloat) -> Font {
.system(size: size, weight: .regular)
}
static func nightMono(_ size: CGFloat) -> Font {
.system(size: size, design: .monospaced)
}
static func nightLabel(_ size: CGFloat, weight: Font.Weight = .medium) -> Font {
.system(size: size, weight: weight)
}
}
@@ -0,0 +1,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) }
}
@@ -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()
}
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SUPABASE_URL</key>
<string>$(SUPABASE_URL)</string>
<key>SUPABASE_ANON_KEY</key>
<string>$(SUPABASE_ANON_KEY)</string>
</dict>
</plist>
+107
View File
@@ -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()
)
]
}
+25
View File
@@ -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
)
}
+67
View File
@@ -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")
}
+42
View File
@@ -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
}()
}
@@ -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
)
}
@@ -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
}
@@ -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)
}
}
@@ -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
}
}
}
@@ -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"
}
}
+303
View File
@@ -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 25 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()
}
}
+395
View File
@@ -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)
}
}
+232
View File
@@ -0,0 +1,232 @@
import SwiftUI
/// Impressum + Datenschutzerklärung
/// Muss vor dem Launch von einem Anwalt geprüft/ergänzt werden!
/// Kosten: ca. 300500 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. 300500€.
"""
}
LegalParagraph(title: "Verantwortlicher") {
"[DEIN NAME], [ADRESSE], [E-MAIL]"
}
LegalParagraph(title: "Welche Daten wir speichern") {
"""
• E-Mail-Adresse (für Account & Passwort-Reset)
• Benutzername und Anzeigename
• Posts, Reaktionen, Kommentare (Inhalte die du selbst erstellst)
• Push-Token (für Benachrichtigungen, optional)
• IP-Adresse in Server-Logs (max. 14 Tage)
"""
}
LegalParagraph(title: "Wofür wir Daten verwenden") {
"""
• Betrieb des Dienstes (Authentifizierung, Feed, Benachrichtigungen)
• Moderation (Meldungen von Inhalten)
• Keine Weitergabe an Dritte außer für den Betrieb notwendige Dienste
"""
}
LegalParagraph(title: "Serverstandort") {
"Alle Daten werden auf Servern in der EU gespeichert."
}
LegalParagraph(title: "Deine Rechte (DSGVO)") {
"""
• Auskunft über gespeicherte Daten: legal@xxx.dk0.dev
• Berichtigung falscher Daten
• Löschung: Account in den Einstellungen löschen — entfernt alle deine Daten sofort
• Datenübertragbarkeit: auf Anfrage per E-Mail
• Widerspruch gegen Verarbeitung: legal@xxx.dk0.dev
• Beschwerde bei der Datenschutzbehörde
"""
}
LegalParagraph(title: "Datenlöschung") {
"""
Posts werden 14 Stunden nach Erstellung aus dem öffentlichen Feed entfernt. \
Dein persönliches Tagebuch behältst du so lange du möchtest. \
Account-Löschung entfernt alle Daten dauerhaft und unwiderruflich.
"""
}
LegalParagraph(title: "Cookies / Tracking") {
"Wir verwenden keine Cookies, keine Tracker, keine Werbenetze."
}
}
}
}
// MARK: - Nutzungsbedingungen
struct NutzungsbedingungenContent: View {
var body: some View {
LegalSection(title: "Nutzungsbedingungen") {
LegalParagraph(title: "⚠️ Entwurf") {
"Diese Nutzungsbedingungen sind ein Entwurf und müssen vor dem Launch von einem Anwalt geprüft werden."
}
LegalParagraph(title: "Nutzung") {
"""
nightly ist ein Dienst für Personen ab 17 Jahren. \
Du bist für die Inhalte die du postest selbst verantwortlich.
"""
}
LegalParagraph(title: "Verbotene Inhalte") {
"""
Folgende Inhalte sind verboten:
• Hassrede, Diskriminierung, Bedrohung
• Belästigung oder Mobbing
• Illegale Inhalte jeglicher Art
• Spam oder kommerzielle Werbung
• Inhalte die andere Personen ohne deren Zustimmung zeigen
"""
}
LegalParagraph(title: "Moderation") {
"""
Gemeldete Inhalte werden geprüft und können ohne Vorankündigung entfernt werden. \
Bei schwerwiegenden Verstößen behalten wir uns die Sperrung des Accounts vor.
"""
}
LegalParagraph(title: "Haftungsausschluss") {
"""
Wir übernehmen keine Haftung für nutzergenerierte Inhalte. \
Der Dienst wird ohne Gewähr für Verfügbarkeit bereitgestellt.
"""
}
}
}
}
// MARK: - Reusable components
struct LegalSection<Content: View>: View {
let title: String
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text(title)
.font(.nightTitle(22))
.foregroundColor(.nightPrimary)
.padding(.bottom, 4)
content
}
}
}
struct LegalParagraph: View {
let title: String
let 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)
}
}
}
@@ -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"
}
}
}
@@ -0,0 +1,306 @@
import SwiftUI
struct OnboardingView: View {
@EnvironmentObject var appState: AppState
@State private var phase: Phase = .welcome
@State private var isLogin = false
enum Phase { case welcome, auth }
var body: some View {
ZStack {
Color.nightBase.ignoresSafeArea()
StarField()
VStack(spacing: 0) {
Spacer()
switch phase {
case .welcome:
WelcomeScreen()
.transition(.opacity)
Spacer()
WelcomeActions(
onStart: {
isLogin = false
withAnimation(.spring(duration: 0.4)) { phase = .auth }
},
onLogin: {
isLogin = true
withAnimation(.spring(duration: 0.4)) { phase = .auth }
}
)
case .auth:
AuthScreen(isLogin: $isLogin)
.environmentObject(appState)
.transition(.move(edge: .trailing).combined(with: .opacity))
Spacer()
}
}
}
.preferredColorScheme(.dark)
}
}
// MARK: - Welcome
struct WelcomeScreen: View {
@State private var appeared = false
var body: some View {
VStack(spacing: 28) {
ZStack {
ForEach([130, 100, 70], id: \.self) { size in
Circle()
.fill(Color.nightPurple.opacity(size == 70 ? 0 : (size == 100 ? 0.08 : 0.04)))
.frame(width: CGFloat(size), height: CGFloat(size))
}
Text("")
.font(.system(size: 52))
.foregroundColor(.nightPurpleSoft)
}
.scaleEffect(appeared ? 1 : 0.75)
.opacity(appeared ? 1 : 0)
VStack(spacing: 12) {
Text("nightly")
.font(.system(size: 44, weight: .bold, design: .rounded))
.foregroundColor(.nightPrimary)
VStack(spacing: 5) {
Text("Zwischen 2 und 5 Uhr.")
Text("Kein Filter. Keine Maske.")
Text("Nur echte Gedanken.")
}
.font(.nightBody(17))
.foregroundColor(.nightSecondary)
.multilineTextAlignment(.center)
.lineSpacing(3)
}
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 10)
}
.onAppear {
withAnimation(.spring(duration: 0.8, bounce: 0.25).delay(0.1)) { appeared = true }
}
}
}
struct WelcomeActions: View {
let onStart: () -> Void
let onLogin: () -> Void
var body: some View {
VStack(spacing: 12) {
Button("loslegen", action: onStart).buttonStyle(NightlyPrimaryButton())
Button("ich hab schon einen account", action: onLogin)
.font(.nightLabel(14))
.foregroundColor(.nightSecondary)
}
.padding(.horizontal, 24)
.padding(.bottom, 52)
}
}
// MARK: - Auth Screen
struct AuthScreen: View {
@EnvironmentObject var appState: AppState
@Binding var isLogin: Bool
// Registrierung
@State private var username = ""
@State private var displayName = ""
@State private var email = ""
@State private var password = ""
@State private var isLoading = false
@State private var error: String?
var body: some View {
VStack(spacing: 22) {
Text(isLogin ? "willkommen zurück" : "mitmachen")
.font(.nightTitle(28))
.foregroundColor(.nightPrimary)
VStack(spacing: 10) {
if !isLogin {
NightlyField(text: $displayName, placeholder: "anzeigename", icon: "person")
NightlyField(text: $username, placeholder: "benutzername", icon: "at")
.textInputAutocapitalization(.never).autocorrectionDisabled()
}
NightlyField(text: $email, placeholder: "e-mail", icon: "envelope")
.textInputAutocapitalization(.never)
.keyboardType(.emailAddress)
.autocorrectionDisabled()
NightlyField(text: $password, placeholder: "passwort", icon: "lock", isSecure: true)
}
.padding(.horizontal, 24)
if let err = error {
Text(err)
.font(.nightLabel(13))
.foregroundColor(.nightRed)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
Button(isLogin ? "einloggen" : "account erstellen") {
Task { await submit() }
}
.buttonStyle(NightlyPrimaryButton(isLoading: isLoading))
.disabled(isLoading)
.padding(.horizontal, 24)
Button(isLogin ? "noch kein account?" : "schon dabei?") {
withAnimation { isLogin.toggle() }
}
.font(.nightLabel(14))
.foregroundColor(.nightSecondary)
// Rechtliches
LegalNotice()
}
}
func submit() async {
guard !email.isEmpty && !password.isEmpty else {
error = "Bitte alle Felder ausfüllen."
return
}
isLoading = true
error = nil
defer { isLoading = false }
do {
if isLogin {
try await appState.signIn(email: email, password: password)
} else {
guard !username.isEmpty && !displayName.isEmpty else {
error = "Bitte alle Felder ausfüllen."
return
}
guard username.count >= 3 else {
error = "Benutzername muss mindestens 3 Zeichen haben."
return
}
guard password.count >= 8 else {
error = "Passwort muss mindestens 8 Zeichen haben."
return
}
try await appState.signUp(
email: email,
password: password,
username: username.lowercased(),
displayName: displayName
)
}
} catch {
self.error = error.localizedDescription
}
}
}
struct LegalNotice: View {
@State private var showLegal = false
var body: some View {
VStack(spacing: 4) {
Text("Mit der Registrierung stimmst du zu:")
.font(.nightLabel(11))
.foregroundColor(.nightTertiary)
HStack(spacing: 4) {
Button("Nutzungsbedingungen") { showLegal = true }
Text("·")
Button("Datenschutzerklärung") { showLegal = true }
}
.font(.nightLabel(11, weight: .medium))
.foregroundColor(.nightSecondary)
}
.multilineTextAlignment(.center)
.sheet(isPresented: $showLegal) {
LegalView()
}
}
}
// MARK: - Reusable components
struct NightlyField: View {
@Binding var text: String
let placeholder: String
let icon: String
var isSecure = false
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 15))
.foregroundColor(.nightSecondary)
.frame(width: 18)
Group {
if isSecure { SecureField(placeholder, text: $text) }
else { TextField(placeholder, text: $text) }
}
.font(.nightBody(16))
.foregroundColor(.nightPrimary)
}
.padding(16)
.background(Color.nightSurface)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Color.nightBorder, lineWidth: 1))
.tint(.nightPurpleSoft)
}
}
struct NightlyPrimaryButton: ButtonStyle {
var isLoading = false
func makeBody(configuration: Configuration) -> some View {
Group {
if isLoading { ProgressView().tint(.nightBase).frame(maxWidth: .infinity, maxHeight: 52) }
else {
configuration.label
.font(.nightLabel(17, weight: .semibold))
.foregroundColor(.nightBase)
.frame(maxWidth: .infinity).frame(height: 52)
}
}
.background(Color.nightPrimary.opacity(configuration.isPressed ? 0.85 : 1))
.clipShape(RoundedRectangle(cornerRadius: 14))
.scaleEffect(configuration.isPressed ? 0.98 : 1)
.animation(.spring(duration: 0.2), value: configuration.isPressed)
}
}
struct StarField: View {
struct Star: Identifiable {
let id: Int; let x, y, size, opacity: CGFloat
}
private let stars: [Star] = (0..<120).map {
Star(id: $0,
x: .random(in: 0...1),
y: .random(in: 0...1),
size: .random(in: 1...2.5),
opacity: .random(in: 0.07...0.3))
}
@State private var twinkle = false
var body: some View {
GeometryReader { geo in
ForEach(stars) { s in
Circle().fill(Color.white)
.frame(width: s.size, height: s.size)
.opacity(twinkle && s.id % 5 == 0 ? s.opacity * 0.3 : s.opacity)
.position(x: s.x * geo.size.width, y: s.y * geo.size.height)
}
}
.ignoresSafeArea()
.onAppear {
withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) { twinkle = true }
}
}
}
@@ -0,0 +1,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
}
}
}
@@ -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
}
}
}
}
@@ -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)
}
}
@@ -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<Content: View>: 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<Content: View>: 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])
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
</dict>
</plist>
@@ -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
}
}
@@ -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 its 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()
}
}
}
@@ -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)
}
}