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:
+41
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Replace with your actual domain
|
||||||
|
api.nightly.app {
|
||||||
|
reverse_proxy api:3000 {
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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'))
|
||||||
@@ -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' })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { hash, verify } from '@node-rs/bcrypt'
|
||||||
|
import jwt from 'jsonwebtoken'
|
||||||
|
import { v4 as uuid } from 'uuid'
|
||||||
|
import { db } from '../index.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.post('/register', async (req, res) => {
|
||||||
|
const { username, password, displayName } = req.body ?? {}
|
||||||
|
|
||||||
|
if (!username || !password || !displayName) {
|
||||||
|
return res.status(400).json({ message: 'Alle Felder erforderlich' })
|
||||||
|
}
|
||||||
|
if (!/^[a-z0-9_.]{3,20}$/.test(username)) {
|
||||||
|
return res.status(400).json({ message: 'Benutzername: 3–20 Zeichen, nur a-z 0-9 _ .' })
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
return res.status(400).json({ message: 'Passwort muss mindestens 8 Zeichen haben' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await db.query('SELECT id FROM users WHERE username = $1', [username])
|
||||||
|
if (rows.length > 0) return res.status(409).json({ message: 'Benutzername bereits vergeben' })
|
||||||
|
|
||||||
|
const passwordHash = await hash(password, 12)
|
||||||
|
const id = uuid()
|
||||||
|
await db.query(
|
||||||
|
'INSERT INTO users (id, username, display_name, password_hash) VALUES ($1,$2,$3,$4)',
|
||||||
|
[id, username, displayName, passwordHash]
|
||||||
|
)
|
||||||
|
|
||||||
|
const token = jwt.sign({ userId: id }, process.env.JWT_SECRET, { expiresIn: '90d' })
|
||||||
|
res.status(201).json({ token })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('register:', err)
|
||||||
|
res.status(500).json({ message: 'Serverfehler' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
const { username, password } = req.body ?? {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await db.query('SELECT * FROM users WHERE username = $1', [username])
|
||||||
|
const user = rows[0]
|
||||||
|
|
||||||
|
if (!user || !await verify(password, user.password_hash)) {
|
||||||
|
return res.status(401).json({ message: 'Benutzername oder Passwort falsch' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: '90d' })
|
||||||
|
res.json({ token })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('login:', err)
|
||||||
|
res.status(500).json({ message: 'Serverfehler' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { db } from '../index.js'
|
||||||
|
import { authenticate } from '../middleware/auth.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
router.use(authenticate)
|
||||||
|
|
||||||
|
router.get('/me', async (req, res) => {
|
||||||
|
const { rows } = await db.query('SELECT * FROM users WHERE id = $1', [req.user.userId])
|
||||||
|
if (!rows[0]) return res.status(404).json({ message: 'Nicht gefunden' })
|
||||||
|
res.json(formatUser(rows[0]))
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/:id/posts', async (req, res) => {
|
||||||
|
const { rows } = await db.query(`
|
||||||
|
SELECT p.*, u.username, u.display_name, u.avatar_url
|
||||||
|
FROM posts p JOIN users u ON u.id = p.user_id
|
||||||
|
WHERE p.user_id = $1 ORDER BY p.created_at DESC LIMIT 50
|
||||||
|
`, [req.params.id])
|
||||||
|
res.json(rows.map(row => ({
|
||||||
|
id: row.id, content: row.content, imageURL: row.image_url,
|
||||||
|
createdAt: row.created_at, nightOf: row.created_at,
|
||||||
|
reactions: {}, myReaction: null, commentCount: 0,
|
||||||
|
author: {
|
||||||
|
id: row.user_id, username: row.username, displayName: row.display_name,
|
||||||
|
avatarURL: row.avatar_url, followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/:id/streak', async (req, res) => {
|
||||||
|
// Count consecutive nights (window = 2–5 AM, normalized to "day before 2 AM")
|
||||||
|
const { rows } = await db.query(`
|
||||||
|
WITH nights AS (
|
||||||
|
SELECT DISTINCT DATE(created_at - INTERVAL '2 hours') AS night
|
||||||
|
FROM posts WHERE user_id = $1
|
||||||
|
ORDER BY night DESC
|
||||||
|
),
|
||||||
|
numbered AS (
|
||||||
|
SELECT night, ROW_NUMBER() OVER (ORDER BY night DESC) AS rn FROM nights
|
||||||
|
)
|
||||||
|
SELECT COUNT(*) AS streak FROM numbered
|
||||||
|
WHERE night = (CURRENT_DATE - (rn - 1) * INTERVAL '1 day')
|
||||||
|
`, [req.params.id])
|
||||||
|
res.json({ streak: parseInt(rows[0]?.streak ?? 0) })
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/me/push-token', async (req, res) => {
|
||||||
|
const { token } = req.body ?? {}
|
||||||
|
if (!token) return res.status(400).json({ message: 'Token fehlt' })
|
||||||
|
await db.query('UPDATE users SET push_token = $1 WHERE id = $2', [token, req.user.userId])
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/:id/follow', async (req, res) => {
|
||||||
|
if (req.params.id === req.user.userId) {
|
||||||
|
return res.status(400).json({ message: 'Du kannst dir selbst nicht folgen' })
|
||||||
|
}
|
||||||
|
await db.query(
|
||||||
|
'INSERT INTO follows (follower_id, following_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
||||||
|
[req.user.userId, req.params.id]
|
||||||
|
)
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/:id/follow', async (req, res) => {
|
||||||
|
await db.query(
|
||||||
|
'DELETE FROM follows WHERE follower_id = $1 AND following_id = $2',
|
||||||
|
[req.user.userId, req.params.id]
|
||||||
|
)
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatUser(u) {
|
||||||
|
return {
|
||||||
|
id: u.id, username: u.username, displayName: u.display_name,
|
||||||
|
bio: u.bio, avatarURL: u.avatar_url,
|
||||||
|
followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -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`)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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()
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Persönliches Tagebuch — alle eigenen Posts, auch die anonymen.
|
||||||
|
/// Bleibt für immer. Das ist der Retention-Hook.
|
||||||
|
struct DiaryView: View {
|
||||||
|
@StateObject private var viewModel = DiaryViewModel()
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ZStack {
|
||||||
|
Color.nightBase.ignoresSafeArea()
|
||||||
|
|
||||||
|
if viewModel.groupedPosts.isEmpty && !viewModel.isLoading {
|
||||||
|
DiaryEmptyView()
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
|
// "Vor genau einem Jahr" — Memory
|
||||||
|
if let memory = viewModel.yearAgoPost {
|
||||||
|
MemoryBanner(post: memory)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(viewModel.groupedPosts, id: \.nightLabel) { group in
|
||||||
|
// Nacht-Header
|
||||||
|
DiaryNightHeader(label: group.nightLabel, count: group.posts.count)
|
||||||
|
|
||||||
|
ForEach(group.posts) { post in
|
||||||
|
DiaryPostRow(post: post) {
|
||||||
|
Task { await viewModel.deletePost(post) }
|
||||||
|
}
|
||||||
|
Divider().background(Color.nightBorder).padding(.leading, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Color.clear.frame(height: 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable { await viewModel.load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .principal) {
|
||||||
|
HStack(spacing: 7) {
|
||||||
|
Image(systemName: "book.closed.fill")
|
||||||
|
.foregroundColor(.nightPurple)
|
||||||
|
Text("tagebuch")
|
||||||
|
.font(.nightTitle(17))
|
||||||
|
.foregroundColor(.nightPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Memory Banner
|
||||||
|
|
||||||
|
struct MemoryBanner: View {
|
||||||
|
let post: Post
|
||||||
|
@State private var show = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if show {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "clock.arrow.circlepath")
|
||||||
|
.foregroundColor(.nightPurpleSoft)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
Text("vor genau einem jahr")
|
||||||
|
.font(.nightLabel(12, weight: .semibold))
|
||||||
|
.foregroundColor(.nightPurpleSoft)
|
||||||
|
.kerning(0.5)
|
||||||
|
Spacer()
|
||||||
|
Button { withAnimation { show = false } } label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(.nightTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(post.content)
|
||||||
|
.font(.nightBody(15))
|
||||||
|
.foregroundColor(.nightPrimary.opacity(0.85))
|
||||||
|
.lineSpacing(4)
|
||||||
|
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
if let mood = post.mood {
|
||||||
|
Text(mood.emoji).font(.nightMono(11))
|
||||||
|
Text(mood.label).font(.nightLabel(11))
|
||||||
|
}
|
||||||
|
Text("·")
|
||||||
|
Text(post.formattedTime)
|
||||||
|
.font(.nightMono(11))
|
||||||
|
}
|
||||||
|
.foregroundColor(.nightTertiary)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 0)
|
||||||
|
.fill(Color.nightPurple.opacity(0.06))
|
||||||
|
RoundedRectangle(cornerRadius: 0)
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.nightPurple.opacity(0.08), .clear],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
Rectangle().fill(Color.nightBorder).frame(height: 1),
|
||||||
|
alignment: .bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Night Group Header
|
||||||
|
|
||||||
|
struct DiaryNightHeader: View {
|
||||||
|
let label: String
|
||||||
|
let count: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.font(.nightLabel(12, weight: .semibold))
|
||||||
|
.foregroundColor(.nightSecondary)
|
||||||
|
.kerning(0.5)
|
||||||
|
Text("· \(count) Gedanke\(count == 1 ? "" : "n")")
|
||||||
|
.font(.nightLabel(12))
|
||||||
|
.foregroundColor(.nightTertiary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color.nightSurface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Diary Post Row
|
||||||
|
|
||||||
|
struct DiaryPostRow: View {
|
||||||
|
let post: Post
|
||||||
|
let onDelete: () -> Void
|
||||||
|
@State private var confirmDelete = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 0) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(post.mood?.color ?? Color.nightTertiary)
|
||||||
|
.frame(width: 2)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text(post.formattedTime)
|
||||||
|
.font(.nightMono(12))
|
||||||
|
.foregroundColor(.nightTertiary)
|
||||||
|
|
||||||
|
if post.isAnonymous {
|
||||||
|
Text("· anonym")
|
||||||
|
.font(.nightLabel(11))
|
||||||
|
.foregroundColor(.nightTertiary)
|
||||||
|
.italic()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let mood = post.mood {
|
||||||
|
Text("· \(mood.emoji) \(mood.label)")
|
||||||
|
.font(.nightLabel(11))
|
||||||
|
.foregroundColor(mood.color.opacity(0.7))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if post.resonanceCount > 0 {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: "heart.fill")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
Text("\(post.resonanceCount)")
|
||||||
|
.font(.nightLabel(11))
|
||||||
|
}
|
||||||
|
.foregroundColor(.nightRed.opacity(0.7))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(post.content)
|
||||||
|
.font(.nightBody(16))
|
||||||
|
.foregroundColor(.nightPrimary.opacity(0.88))
|
||||||
|
.lineSpacing(5)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.contextMenu {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
confirmDelete = true
|
||||||
|
} label: {
|
||||||
|
Label("Löschen", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Post löschen?",
|
||||||
|
isPresented: $confirmDelete,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Löschen", role: .destructive) { onDelete() }
|
||||||
|
} message: {
|
||||||
|
Text("Der Post wird aus dem Feed entfernt, bleibt aber in deinem Tagebuch-Archiv.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Empty State
|
||||||
|
|
||||||
|
struct DiaryEmptyView: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 18) {
|
||||||
|
Image(systemName: "book.closed")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundColor(.nightTertiary)
|
||||||
|
Text("noch nichts hier")
|
||||||
|
.font(.nightTitle(18))
|
||||||
|
.foregroundColor(.nightPrimary)
|
||||||
|
Text("Deine Posts landen hier.\nIn einem Jahr kannst du nachlesen, was dich\nmitten in der Nacht beschäftigt hat.")
|
||||||
|
.font(.nightBody(15))
|
||||||
|
.foregroundColor(.nightSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineSpacing(4)
|
||||||
|
}
|
||||||
|
.padding(40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ViewModel
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class DiaryViewModel: ObservableObject {
|
||||||
|
struct NightGroup { let nightLabel: String; let posts: [Post] }
|
||||||
|
|
||||||
|
@Published var groupedPosts: [NightGroup] = []
|
||||||
|
@Published var yearAgoPost: Post? = nil
|
||||||
|
@Published var isLoading = false
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let posts = try await supabase.getDiary()
|
||||||
|
|
||||||
|
// Gruppiere nach Nacht (Datum - 2h, damit 2–5 Uhr zur selben Nacht gehört)
|
||||||
|
let grouped = Dictionary(grouping: posts) { post -> String in
|
||||||
|
let nightDate = post.createdAt.addingTimeInterval(-2 * 3600)
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "de_DE")
|
||||||
|
f.dateFormat = "EEEE, d. MMMM yyyy"
|
||||||
|
return f.string(from: nightDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
groupedPosts = grouped
|
||||||
|
.sorted { $0.key > $1.key }
|
||||||
|
.map { NightGroup(nightLabel: $0.key, posts: $0.value) }
|
||||||
|
|
||||||
|
// Memory: Post von vor genau einem Jahr
|
||||||
|
let oneYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: Date())!
|
||||||
|
yearAgoPost = posts.first { post in
|
||||||
|
Calendar.current.isDate(post.createdAt, equalTo: oneYearAgo, toGranularity: .day)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
|
groupedPosts = [NightGroup(nightLabel: "gestern", posts: Post.previews)]
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deletePost(_ post: Post) async {
|
||||||
|
try? await supabase.softDeletePost(id: post.id)
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Impressum + Datenschutzerklärung
|
||||||
|
/// ⚠️ Muss vor dem Launch von einem Anwalt geprüft/ergänzt werden!
|
||||||
|
/// Kosten: ca. 300–500€ einmalig für Impressum + AGB + DSGVO-Datenschutzerklärung
|
||||||
|
struct LegalView: View {
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@State private var tab = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ZStack {
|
||||||
|
Color.nightBase.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Tab Switcher
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(["impressum", "datenschutz", "nutzungsbedingungen"], id: \.self) { label in
|
||||||
|
let idx = ["impressum", "datenschutz", "nutzungsbedingungen"].firstIndex(of: label)!
|
||||||
|
Button(label) { tab = idx }
|
||||||
|
.font(.nightLabel(12, weight: tab == idx ? .semibold : .regular))
|
||||||
|
.foregroundColor(tab == idx ? .nightPrimary : .nightSecondary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.overlay(
|
||||||
|
Rectangle()
|
||||||
|
.fill(tab == idx ? Color.nightPurple : .clear)
|
||||||
|
.frame(height: 2),
|
||||||
|
alignment: .bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.overlay(Rectangle().fill(Color.nightBorder).frame(height: 1), alignment: .bottom)
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
switch tab {
|
||||||
|
case 0: ImpressumContent()
|
||||||
|
case 1: DatenschutzContent()
|
||||||
|
default: NutzungsbedingungenContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Schließen") { dismiss() }
|
||||||
|
.foregroundColor(.nightSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Impressum
|
||||||
|
|
||||||
|
struct ImpressumContent: View {
|
||||||
|
var body: some View {
|
||||||
|
LegalSection(title: "Impressum") {
|
||||||
|
// ⚠️ PFLICHTANGABEN — vor Launch ausfüllen!
|
||||||
|
LegalParagraph(title: "Angaben gemäß § 5 TMG") {
|
||||||
|
"""
|
||||||
|
[DEIN NAME]
|
||||||
|
[STRASSE HAUSNUMMER]
|
||||||
|
[PLZ ORT]
|
||||||
|
Deutschland
|
||||||
|
|
||||||
|
⚠️ Vor Launch ausfüllen!
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Kontakt") {
|
||||||
|
"""
|
||||||
|
E-Mail: legal@xxx.dk0.dev
|
||||||
|
|
||||||
|
⚠️ Vor Launch ausfüllen!
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV") {
|
||||||
|
"[DEIN NAME], [ADRESSE]"
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Hinweis") {
|
||||||
|
"""
|
||||||
|
Diese App ist ein privates Projekt. Für die Richtigkeit, \
|
||||||
|
Vollständigkeit und Aktualität der Inhalte kann keine Gewähr übernommen werden.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Datenschutz
|
||||||
|
|
||||||
|
struct DatenschutzContent: View {
|
||||||
|
var body: some View {
|
||||||
|
LegalSection(title: "Datenschutzerklärung") {
|
||||||
|
LegalParagraph(title: "⚠️ Hinweis") {
|
||||||
|
"""
|
||||||
|
Diese Datenschutzerklärung ist ein Entwurf und muss vor dem Launch \
|
||||||
|
von einem Datenschutzanwalt geprüft und vervollständigt werden. \
|
||||||
|
Kosten: ca. 300–500€.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Verantwortlicher") {
|
||||||
|
"[DEIN NAME], [ADRESSE], [E-MAIL]"
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Welche Daten wir speichern") {
|
||||||
|
"""
|
||||||
|
• E-Mail-Adresse (für Account & Passwort-Reset)
|
||||||
|
• Benutzername und Anzeigename
|
||||||
|
• Posts, Reaktionen, Kommentare (Inhalte die du selbst erstellst)
|
||||||
|
• Push-Token (für Benachrichtigungen, optional)
|
||||||
|
• IP-Adresse in Server-Logs (max. 14 Tage)
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Wofür wir Daten verwenden") {
|
||||||
|
"""
|
||||||
|
• Betrieb des Dienstes (Authentifizierung, Feed, Benachrichtigungen)
|
||||||
|
• Moderation (Meldungen von Inhalten)
|
||||||
|
• Keine Weitergabe an Dritte außer für den Betrieb notwendige Dienste
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Serverstandort") {
|
||||||
|
"Alle Daten werden auf Servern in der EU gespeichert."
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Deine Rechte (DSGVO)") {
|
||||||
|
"""
|
||||||
|
• Auskunft über gespeicherte Daten: legal@xxx.dk0.dev
|
||||||
|
• Berichtigung falscher Daten
|
||||||
|
• Löschung: Account in den Einstellungen löschen — entfernt alle deine Daten sofort
|
||||||
|
• Datenübertragbarkeit: auf Anfrage per E-Mail
|
||||||
|
• Widerspruch gegen Verarbeitung: legal@xxx.dk0.dev
|
||||||
|
• Beschwerde bei der Datenschutzbehörde
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Datenlöschung") {
|
||||||
|
"""
|
||||||
|
Posts werden 14 Stunden nach Erstellung aus dem öffentlichen Feed entfernt. \
|
||||||
|
Dein persönliches Tagebuch behältst du so lange du möchtest. \
|
||||||
|
Account-Löschung entfernt alle Daten dauerhaft und unwiderruflich.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Cookies / Tracking") {
|
||||||
|
"Wir verwenden keine Cookies, keine Tracker, keine Werbenetze."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Nutzungsbedingungen
|
||||||
|
|
||||||
|
struct NutzungsbedingungenContent: View {
|
||||||
|
var body: some View {
|
||||||
|
LegalSection(title: "Nutzungsbedingungen") {
|
||||||
|
LegalParagraph(title: "⚠️ Entwurf") {
|
||||||
|
"Diese Nutzungsbedingungen sind ein Entwurf und müssen vor dem Launch von einem Anwalt geprüft werden."
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Nutzung") {
|
||||||
|
"""
|
||||||
|
nightly ist ein Dienst für Personen ab 17 Jahren. \
|
||||||
|
Du bist für die Inhalte die du postest selbst verantwortlich.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Verbotene Inhalte") {
|
||||||
|
"""
|
||||||
|
Folgende Inhalte sind verboten:
|
||||||
|
• Hassrede, Diskriminierung, Bedrohung
|
||||||
|
• Belästigung oder Mobbing
|
||||||
|
• Illegale Inhalte jeglicher Art
|
||||||
|
• Spam oder kommerzielle Werbung
|
||||||
|
• Inhalte die andere Personen ohne deren Zustimmung zeigen
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Moderation") {
|
||||||
|
"""
|
||||||
|
Gemeldete Inhalte werden geprüft und können ohne Vorankündigung entfernt werden. \
|
||||||
|
Bei schwerwiegenden Verstößen behalten wir uns die Sperrung des Accounts vor.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Haftungsausschluss") {
|
||||||
|
"""
|
||||||
|
Wir übernehmen keine Haftung für nutzergenerierte Inhalte. \
|
||||||
|
Der Dienst wird ohne Gewähr für Verfügbarkeit bereitgestellt.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reusable components
|
||||||
|
|
||||||
|
struct LegalSection<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
+5
@@ -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>
|
||||||
+69
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Persönliches Tagebuch — alle eigenen Posts, auch die anonymen.
|
||||||
|
/// Bleibt für immer. Das ist der Retention-Hook.
|
||||||
|
struct DiaryView: View {
|
||||||
|
@StateObject private var viewModel = DiaryViewModel()
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
@State private var postsAppeared = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ZStack {
|
||||||
|
Color.nightBase.ignoresSafeArea()
|
||||||
|
|
||||||
|
if viewModel.groupedPosts.isEmpty && !viewModel.isLoading {
|
||||||
|
DiaryEmptyView()
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
|
// "Vor genau einem Jahr" — Memory
|
||||||
|
if let memory = viewModel.yearAgoPost {
|
||||||
|
MemoryBanner(post: memory)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(Array(viewModel.groupedPosts.enumerated()), id: \.element.nightLabel) { groupIndex, group in
|
||||||
|
// Nacht-Header
|
||||||
|
DiaryNightHeader(label: group.nightLabel, count: group.posts.count)
|
||||||
|
.opacity(postsAppeared ? 1 : 0)
|
||||||
|
.animation(
|
||||||
|
.spring(duration: 0.35, bounce: 0.1)
|
||||||
|
.delay(Double(min(groupIndex, 6)) * 0.06),
|
||||||
|
value: postsAppeared
|
||||||
|
)
|
||||||
|
|
||||||
|
ForEach(group.posts) { post in
|
||||||
|
DiaryPostRow(post: post) {
|
||||||
|
Task { await viewModel.deletePost(post) }
|
||||||
|
}
|
||||||
|
.opacity(postsAppeared ? 1 : 0)
|
||||||
|
.offset(y: postsAppeared ? 0 : 12)
|
||||||
|
.animation(
|
||||||
|
.spring(duration: 0.35, bounce: 0.1)
|
||||||
|
.delay(Double(min(groupIndex, 6)) * 0.06 + 0.03),
|
||||||
|
value: postsAppeared
|
||||||
|
)
|
||||||
|
Divider().background(Color.nightBorder).padding(.leading, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { postsAppeared = true }
|
||||||
|
Color.clear.frame(height: 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable { await viewModel.load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .principal) {
|
||||||
|
HStack(spacing: 7) {
|
||||||
|
Image(systemName: "book.closed.fill")
|
||||||
|
.foregroundColor(.nightPurple)
|
||||||
|
Text("tagebuch")
|
||||||
|
.font(.nightTitle(17))
|
||||||
|
.foregroundColor(.nightPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Memory Banner
|
||||||
|
|
||||||
|
struct MemoryBanner: View {
|
||||||
|
let post: Post
|
||||||
|
@State private var show = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if show {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "clock.arrow.circlepath")
|
||||||
|
.foregroundColor(.nightPurpleSoft)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
Text("vor genau einem jahr")
|
||||||
|
.font(.nightLabel(12, weight: .semibold))
|
||||||
|
.foregroundColor(.nightPurpleSoft)
|
||||||
|
.kerning(0.5)
|
||||||
|
Spacer()
|
||||||
|
Button { withAnimation { show = false } } label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(.nightTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(post.content)
|
||||||
|
.font(.nightBody(15))
|
||||||
|
.foregroundColor(.nightPrimary.opacity(0.85))
|
||||||
|
.lineSpacing(4)
|
||||||
|
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
if let mood = post.mood {
|
||||||
|
Text(mood.emoji).font(.nightMono(11))
|
||||||
|
Text(mood.label).font(.nightLabel(11))
|
||||||
|
}
|
||||||
|
Text("·")
|
||||||
|
Text(post.formattedTime)
|
||||||
|
.font(.nightMono(11))
|
||||||
|
}
|
||||||
|
.foregroundColor(.nightTertiary)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 0)
|
||||||
|
.fill(Color.nightPurple.opacity(0.06))
|
||||||
|
RoundedRectangle(cornerRadius: 0)
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.nightPurple.opacity(0.08), .clear],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
Rectangle().fill(Color.nightBorder).frame(height: 1),
|
||||||
|
alignment: .bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Night Group Header
|
||||||
|
|
||||||
|
struct DiaryNightHeader: View {
|
||||||
|
let label: String
|
||||||
|
let count: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.font(.nightLabel(12, weight: .semibold))
|
||||||
|
.foregroundColor(.nightSecondary)
|
||||||
|
.kerning(0.5)
|
||||||
|
Text("· \(count) Gedanke\(count == 1 ? "" : "n")")
|
||||||
|
.font(.nightLabel(12))
|
||||||
|
.foregroundColor(.nightTertiary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color.nightSurface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Diary Post Row
|
||||||
|
|
||||||
|
struct DiaryPostRow: View {
|
||||||
|
let post: Post
|
||||||
|
let onDelete: () -> Void
|
||||||
|
@State private var confirmDelete = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 0) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(post.mood?.color ?? Color.nightTertiary)
|
||||||
|
.frame(width: 2)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text(post.formattedTime)
|
||||||
|
.font(.nightMono(12))
|
||||||
|
.foregroundColor(.nightTertiary)
|
||||||
|
|
||||||
|
if post.isAnonymous {
|
||||||
|
Text("· anonym")
|
||||||
|
.font(.nightLabel(11))
|
||||||
|
.foregroundColor(.nightTertiary)
|
||||||
|
.italic()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let mood = post.mood {
|
||||||
|
Text("· \(mood.emoji) \(mood.label)")
|
||||||
|
.font(.nightLabel(11))
|
||||||
|
.foregroundColor(mood.color.opacity(0.7))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if post.resonanceCount > 0 {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: "heart.fill")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
Text("\(post.resonanceCount)")
|
||||||
|
.font(.nightLabel(11))
|
||||||
|
}
|
||||||
|
.foregroundColor(.nightRed.opacity(0.7))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(post.content)
|
||||||
|
.font(.nightBody(16))
|
||||||
|
.foregroundColor(.nightPrimary.opacity(0.88))
|
||||||
|
.lineSpacing(5)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.contextMenu {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
confirmDelete = true
|
||||||
|
} label: {
|
||||||
|
Label("Löschen", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Post löschen?",
|
||||||
|
isPresented: $confirmDelete,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Löschen", role: .destructive) { onDelete() }
|
||||||
|
} message: {
|
||||||
|
Text("Der Post wird aus dem Feed entfernt, bleibt aber in deinem Tagebuch-Archiv.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Empty State
|
||||||
|
|
||||||
|
struct DiaryEmptyView: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 18) {
|
||||||
|
Image(systemName: "book.closed")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundColor(.nightTertiary)
|
||||||
|
Text("noch nichts hier")
|
||||||
|
.font(.nightTitle(18))
|
||||||
|
.foregroundColor(.nightPrimary)
|
||||||
|
Text("Deine Posts landen hier.\nIn einem Jahr kannst du nachlesen, was dich\nmitten in der Nacht beschäftigt hat.")
|
||||||
|
.font(.nightBody(15))
|
||||||
|
.foregroundColor(.nightSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineSpacing(4)
|
||||||
|
}
|
||||||
|
.padding(40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ViewModel
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class DiaryViewModel: ObservableObject {
|
||||||
|
struct NightGroup { let nightLabel: String; let posts: [Post] }
|
||||||
|
|
||||||
|
@Published var groupedPosts: [NightGroup] = []
|
||||||
|
@Published var yearAgoPost: Post? = nil
|
||||||
|
@Published var isLoading = false
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let posts = try await supabase.getDiary()
|
||||||
|
|
||||||
|
// Gruppiere nach Nacht (Datum - 2h, damit 2–5 Uhr zur selben Nacht gehört)
|
||||||
|
let grouped = Dictionary(grouping: posts) { post -> String in
|
||||||
|
let nightDate = post.createdAt.addingTimeInterval(-2 * 3600)
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "de_DE")
|
||||||
|
f.dateFormat = "EEEE, d. MMMM yyyy"
|
||||||
|
return f.string(from: nightDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
groupedPosts = grouped
|
||||||
|
.sorted { $0.key > $1.key }
|
||||||
|
.map { NightGroup(nightLabel: $0.key, posts: $0.value) }
|
||||||
|
|
||||||
|
// Memory: Post von vor genau einem Jahr
|
||||||
|
let oneYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: Date())!
|
||||||
|
yearAgoPost = posts.first { post in
|
||||||
|
Calendar.current.isDate(post.createdAt, equalTo: oneYearAgo, toGranularity: .day)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
|
groupedPosts = [NightGroup(nightLabel: "gestern", posts: Post.previews)]
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deletePost(_ post: Post) async {
|
||||||
|
try? await supabase.softDeletePost(id: post.id)
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Impressum + Datenschutzerklärung
|
||||||
|
/// ⚠️ Muss vor dem Launch von einem Anwalt geprüft/ergänzt werden!
|
||||||
|
/// Kosten: ca. 300–500€ einmalig für Impressum + AGB + DSGVO-Datenschutzerklärung
|
||||||
|
struct LegalView: View {
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@State private var tab = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ZStack {
|
||||||
|
Color.nightBase.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Tab Switcher
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(["impressum", "datenschutz", "nutzungsbedingungen"], id: \.self) { label in
|
||||||
|
let idx = ["impressum", "datenschutz", "nutzungsbedingungen"].firstIndex(of: label)!
|
||||||
|
Button(label) { tab = idx }
|
||||||
|
.font(.nightLabel(12, weight: tab == idx ? .semibold : .regular))
|
||||||
|
.foregroundColor(tab == idx ? .nightPrimary : .nightSecondary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.overlay(
|
||||||
|
Rectangle()
|
||||||
|
.fill(tab == idx ? Color.nightPurple : .clear)
|
||||||
|
.frame(height: 2),
|
||||||
|
alignment: .bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.overlay(Rectangle().fill(Color.nightBorder).frame(height: 1), alignment: .bottom)
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
switch tab {
|
||||||
|
case 0: ImpressumContent()
|
||||||
|
case 1: DatenschutzContent()
|
||||||
|
default: NutzungsbedingungenContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Schließen") { dismiss() }
|
||||||
|
.foregroundColor(.nightSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Impressum
|
||||||
|
|
||||||
|
struct ImpressumContent: View {
|
||||||
|
var body: some View {
|
||||||
|
LegalSection(title: "Impressum") {
|
||||||
|
// ⚠️ PFLICHTANGABEN — vor Launch ausfüllen!
|
||||||
|
LegalParagraph(title: "Angaben gemäß § 5 TMG") {
|
||||||
|
"""
|
||||||
|
[DEIN NAME]
|
||||||
|
[STRASSE HAUSNUMMER]
|
||||||
|
[PLZ ORT]
|
||||||
|
Deutschland
|
||||||
|
|
||||||
|
⚠️ Vor Launch ausfüllen!
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Kontakt") {
|
||||||
|
"""
|
||||||
|
E-Mail: legal@xxx.dk0.dev
|
||||||
|
|
||||||
|
⚠️ Vor Launch ausfüllen!
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV") {
|
||||||
|
"[DEIN NAME], [ADRESSE]"
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Hinweis") {
|
||||||
|
"""
|
||||||
|
Diese App ist ein privates Projekt. Für die Richtigkeit, \
|
||||||
|
Vollständigkeit und Aktualität der Inhalte kann keine Gewähr übernommen werden.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Datenschutz
|
||||||
|
|
||||||
|
struct DatenschutzContent: View {
|
||||||
|
var body: some View {
|
||||||
|
LegalSection(title: "Datenschutzerklärung") {
|
||||||
|
LegalParagraph(title: "⚠️ Hinweis") {
|
||||||
|
"""
|
||||||
|
Diese Datenschutzerklärung ist ein Entwurf und muss vor dem Launch \
|
||||||
|
von einem Datenschutzanwalt geprüft und vervollständigt werden. \
|
||||||
|
Kosten: ca. 300–500€.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Verantwortlicher") {
|
||||||
|
"[DEIN NAME], [ADRESSE], [E-MAIL]"
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Welche Daten wir speichern") {
|
||||||
|
"""
|
||||||
|
• E-Mail-Adresse (für Account & Passwort-Reset)
|
||||||
|
• Benutzername und Anzeigename
|
||||||
|
• Posts, Reaktionen, Kommentare (Inhalte die du selbst erstellst)
|
||||||
|
• Push-Token (für Benachrichtigungen, optional)
|
||||||
|
• IP-Adresse in Server-Logs (max. 14 Tage)
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Wofür wir Daten verwenden") {
|
||||||
|
"""
|
||||||
|
• Betrieb des Dienstes (Authentifizierung, Feed, Benachrichtigungen)
|
||||||
|
• Moderation (Meldungen von Inhalten)
|
||||||
|
• Keine Weitergabe an Dritte außer für den Betrieb notwendige Dienste
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Serverstandort") {
|
||||||
|
"Alle Daten werden auf Servern in der EU gespeichert."
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Deine Rechte (DSGVO)") {
|
||||||
|
"""
|
||||||
|
• Auskunft über gespeicherte Daten: legal@xxx.dk0.dev
|
||||||
|
• Berichtigung falscher Daten
|
||||||
|
• Löschung: Account in den Einstellungen löschen — entfernt alle deine Daten sofort
|
||||||
|
• Datenübertragbarkeit: auf Anfrage per E-Mail
|
||||||
|
• Widerspruch gegen Verarbeitung: legal@xxx.dk0.dev
|
||||||
|
• Beschwerde bei der Datenschutzbehörde
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Datenlöschung") {
|
||||||
|
"""
|
||||||
|
Posts werden 14 Stunden nach Erstellung aus dem öffentlichen Feed entfernt. \
|
||||||
|
Dein persönliches Tagebuch behältst du so lange du möchtest. \
|
||||||
|
Account-Löschung entfernt alle Daten dauerhaft und unwiderruflich.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Cookies / Tracking") {
|
||||||
|
"Wir verwenden keine Cookies, keine Tracker, keine Werbenetze."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Nutzungsbedingungen
|
||||||
|
|
||||||
|
struct NutzungsbedingungenContent: View {
|
||||||
|
var body: some View {
|
||||||
|
LegalSection(title: "Nutzungsbedingungen") {
|
||||||
|
LegalParagraph(title: "⚠️ Entwurf") {
|
||||||
|
"Diese Nutzungsbedingungen sind ein Entwurf und müssen vor dem Launch von einem Anwalt geprüft werden."
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Nutzung") {
|
||||||
|
"""
|
||||||
|
nightly ist ein Dienst für Personen ab 17 Jahren. \
|
||||||
|
Du bist für die Inhalte die du postest selbst verantwortlich.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Verbotene Inhalte") {
|
||||||
|
"""
|
||||||
|
Folgende Inhalte sind verboten:
|
||||||
|
• Hassrede, Diskriminierung, Bedrohung
|
||||||
|
• Belästigung oder Mobbing
|
||||||
|
• Illegale Inhalte jeglicher Art
|
||||||
|
• Spam oder kommerzielle Werbung
|
||||||
|
• Inhalte die andere Personen ohne deren Zustimmung zeigen
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Moderation") {
|
||||||
|
"""
|
||||||
|
Gemeldete Inhalte werden geprüft und können ohne Vorankündigung entfernt werden. \
|
||||||
|
Bei schwerwiegenden Verstößen behalten wir uns die Sperrung des Accounts vor.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
LegalParagraph(title: "Haftungsausschluss") {
|
||||||
|
"""
|
||||||
|
Wir übernehmen keine Haftung für nutzergenerierte Inhalte. \
|
||||||
|
Der Dienst wird ohne Gewähr für Verfügbarkeit bereitgestellt.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reusable components
|
||||||
|
|
||||||
|
struct LegalSection<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 it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDownWithError() throws {
|
||||||
|
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testExample() throws {
|
||||||
|
// UI tests must launch the application that they test.
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||||
|
// XCUIAutomation Documentation
|
||||||
|
// https://developer.apple.com/documentation/xcuiautomation
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testLaunchPerformance() throws {
|
||||||
|
// This measures how long it takes to launch your application.
|
||||||
|
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||||
|
XCUIApplication().launch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user