merge: integrate upgrade/sdk-54 into main

Reconciles 20 sdk-54 commits with 10 main commits.

Mobile (Expo):
- KeyboardAwareSheet migrations + Snake/Tetris UI + Lyra-feedback game-over flow
- Dark Theme system (Wave 1 + Wave 2 — global color-tokens)
- Profile Avatar + Nickname edit-flow
- Sound system for games (useSnakeSounds)
- Best-score persistence + share-to-community

Admin (Nuxt):
- Phase 2 backend (Users + Moderation endpoints + 2 schema migrations)
- Phase 2 frontend (Domains/Stats/Users/Moderation pages, responsive layout)
- Lyra-Posts feature migration from legacy nuxt-rebreak

Backend (kept main's versions for stability):
- IMAP IDLE-daemon: GMX silent-drop fix (10min renew, 2min NOOP heartbeat)
- mail/status: connect-error tracking + heartbeat fields
- coach/speak: explicit voice-quota helper imports
- prisma: preserved gameName field (production DB column exists)

Conflict resolutions:
- apps/admin/pages/index.vue: theirs (sdk-54 adds Lyra-Posts quick-link)
- apps/rebreak-native/app/lyra.tsx: theirs (Dark-Theme color binding)
- locales/de.json + en.json: theirs (game-rating + share strings)
- GameOverScreen.tsx: theirs (full new feature, 505 vs 256 lines)
- UrgeGames.tsx: theirs (consistent with new GameOverScreen props)
- backend/imap-idle/index.mjs: ours (preserves GMX-fix + heartbeat)
- backend/prisma/schema.prisma: ours (preserves gameName field on prod DB)
- backend/server/api/coach/speak.post.ts: ours (explicit imports fix)
- backend/server/api/mail/status.get.ts: ours (cleaner without type-cast)
- apps/admin/start-admin-staging.sh: ours (preserves PORT-3017 override fix)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-11 02:24:32 +02:00
commit 2e6785e5a3
151 changed files with 15921 additions and 3776 deletions

94
.github/workflows/maestro-cloud.yml vendored Normal file
View File

@ -0,0 +1,94 @@
# Maestro Cloud — E2E template for rebreak-native.
# STATUS: TEMPLATE ONLY — not active. Requires User confirmation before enabling.
#
# Trigger: manual dispatch OR PR to main (commented out — enable after User GO).
# Requires:
# - MAESTRO_CLOUD_API_KEY in GitHub Actions secrets
# - EAS_TOKEN in GitHub Actions secrets
# - E2E_TEST_USER + E2E_TEST_PASSWORD in GitHub Actions secrets
# - Maestro Cloud account configured at mobile.dev
name: Maestro Cloud E2E (rebreak-native)
on:
workflow_dispatch:
inputs:
platform:
description: "Target platform"
required: true
default: "ios"
type: choice
options:
- ios
- android
# Uncomment to run on PRs — only after User approval:
# pull_request:
# branches: [main]
# paths:
# - "apps/rebreak-native/**"
# - "apps/rebreak-native/.maestro/**"
jobs:
maestro-cloud:
name: E2E (${{ inputs.platform || 'ios' }})
runs-on: ubuntu-latest
# Skip entirely if Maestro Cloud key is not configured —
# avoids CI failure on forks or before Cloud is set up.
if: ${{ secrets.MAESTRO_CLOUD_API_KEY != '' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: apps/rebreak-native
# Build app via EAS — requires EAS_TOKEN secret and eas.json configured.
# Profile "preview" must produce a .ipa (iOS) or .apk (Android).
- name: Build with EAS
uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EAS_TOKEN }}
- name: EAS Build
run: |
eas build \
--platform ${{ inputs.platform || 'ios' }} \
--profile preview \
--non-interactive \
--output ./build-artifact
working-directory: apps/rebreak-native
# Install Maestro CLI
- name: Install Maestro CLI
run: curl -Ls "https://get.maestro.mobile.dev" | bash
env:
MAESTRO_VERSION: 1.39.0
- name: Add Maestro to PATH
run: echo "$HOME/.maestro/bin" >> $GITHUB_PATH
# Upload build + run flows on Maestro Cloud
- name: Run Maestro Cloud
run: |
maestro cloud \
--apiKey ${{ secrets.MAESTRO_CLOUD_API_KEY }} \
--app ./build-artifact \
--device ${{ inputs.platform || 'ios' }} \
--env=E2E_TEST_USER=${{ secrets.E2E_TEST_USER }} \
--env=E2E_TEST_PASSWORD=${{ secrets.E2E_TEST_PASSWORD }} \
apps/rebreak-native/.maestro/
working-directory: ${{ github.workspace }}

View File

@ -75,5 +75,12 @@ const quickLinks = [
icon: "heroicons:flag",
to: "/moderation",
},
{
label: "Lyra-Posts",
value: "→",
hint: "Als Lyra oder ReBreak posten",
icon: "heroicons:sparkles",
to: "/lyra",
},
]
</script>

297
apps/admin/pages/lyra.vue Normal file
View File

@ -0,0 +1,297 @@
<template>
<div>
<div class="mb-8">
<h1 class="text-xl font-semibold text-white mb-1">Lyra-Posts</h1>
<p class="text-sm text-gray-500">
Community-Posts als Lyra oder ReBreak-Account erstellen (KI-generiert oder manuell).
</p>
</div>
<!-- Author Selection -->
<div class="mb-6">
<p class="text-xs text-gray-500 mb-2 uppercase tracking-wide font-medium">Als wer posten?</p>
<div class="grid grid-cols-2 gap-3 max-w-sm">
<button
class="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
:class="
author === 'lyra'
? 'border-purple-700 bg-purple-950/40'
: 'border-gray-800 bg-gray-900 hover:border-gray-700'
"
@click="selectAuthor('lyra')"
>
<div class="w-8 h-8 rounded-full bg-purple-950/60 border border-purple-800/50 flex items-center justify-center shrink-0">
<img v-if="lyraAvatar" :src="lyraAvatar" alt="Lyra" class="w-8 h-8 rounded-full object-cover" />
<UIcon v-else name="heroicons:cpu-chip" class="h-4 w-4 text-purple-400" />
</div>
<div class="min-w-0">
<p class="text-sm font-semibold text-white leading-tight">{{ lyraNickname }}</p>
<p class="text-xs text-gray-500">KI-Coach</p>
</div>
</button>
<button
class="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
:class="
author === 'rebreak'
? 'border-blue-700 bg-blue-950/40'
: 'border-gray-800 bg-gray-900 hover:border-gray-700'
"
@click="selectAuthor('rebreak')"
>
<div class="w-8 h-8 rounded-full bg-blue-950/60 border border-blue-800/50 flex items-center justify-center shrink-0">
<img v-if="rebreakAvatar" :src="rebreakAvatar" alt="ReBreak" class="w-8 h-8 rounded-full object-cover" />
<UIcon v-else name="heroicons:shield-check" class="h-4 w-4 text-blue-400" />
</div>
<div class="min-w-0">
<p class="text-sm font-semibold text-white leading-tight">{{ rebreakNickname }}</p>
<p class="text-xs text-gray-500">Offiziell</p>
</div>
</button>
</div>
</div>
<!-- Topic Selection -->
<div class="mb-6">
<p class="text-xs text-gray-500 mb-2 uppercase tracking-wide font-medium">Thema (fuer KI-Generierung)</p>
<div class="flex flex-wrap gap-2">
<button
v-for="t in lyraTopics"
:key="t.value"
class="px-3 py-1.5 rounded-full text-xs font-medium transition-colors border"
:class="
lyraTopic === t.value
? 'bg-purple-600 border-purple-600 text-white'
: 'bg-gray-900 border-gray-700 text-gray-400 hover:border-gray-600 hover:text-gray-300'
"
@click="lyraTopic = t.value"
>
{{ t.emoji }} {{ t.label }}
</button>
</div>
</div>
<!-- Context + Generate -->
<div class="flex gap-3 items-end mb-6">
<div class="flex-1">
<label class="text-xs text-gray-500 uppercase tracking-wide font-medium mb-1.5 block">
Optionaler Kontext
</label>
<input
v-model="lyraContext"
placeholder="z.B. 'DNS-Blocker Update verfuegbar'"
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-gray-600 transition-colors"
/>
</div>
<UButton
:loading="lyraGenerating"
:disabled="lyraPosting"
color="neutral"
variant="outline"
icon="heroicons:sparkles"
@click="generateContent"
>
Generieren
</UButton>
</div>
<!-- Editable Content -->
<div class="mb-6">
<div class="flex items-center justify-between mb-1.5">
<label class="text-xs text-gray-500 uppercase tracking-wide font-medium">
Post-Text
<span class="text-gray-600 normal-case font-normal ml-1">(direkt editierbar)</span>
</label>
<button
v-if="lyraContent"
class="text-xs text-gray-600 hover:text-gray-400 transition-colors"
@click="lyraContent = ''"
>
leeren
</button>
</div>
<textarea
v-model="lyraContent"
placeholder="Text hier eingeben oder via 'Generieren' erstellen..."
rows="5"
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 resize-none focus:outline-none focus:border-gray-600 transition-colors"
/>
</div>
<!-- Preview -->
<div v-if="lyraContent.trim()" class="mb-6 rounded-lg border border-gray-800 bg-gray-900 p-5">
<p class="text-xs text-gray-600 uppercase tracking-wide font-semibold mb-4">Vorschau</p>
<div class="flex items-start gap-3">
<div class="w-9 h-9 rounded-full bg-gray-800 border border-gray-700 flex items-center justify-center shrink-0 overflow-hidden">
<img
v-if="currentAuthorAvatar"
:src="currentAuthorAvatar"
:alt="currentAuthorNickname"
class="w-9 h-9 object-cover"
/>
<UIcon
v-else
:name="author === 'rebreak' ? 'heroicons:shield-check' : 'heroicons:cpu-chip'"
class="h-4 w-4 text-gray-500"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1.5">
<span class="text-sm font-semibold text-white">{{ currentAuthorNickname }}</span>
<span class="text-xs text-purple-400 bg-purple-950/60 px-1.5 py-0.5 rounded-full flex items-center gap-0.5">
<UIcon name="heroicons:cpu-chip" class="h-3 w-3" />
KI
</span>
</div>
<p class="text-sm text-gray-200 leading-relaxed whitespace-pre-wrap">{{ lyraContent }}</p>
</div>
</div>
</div>
<!-- Error -->
<div
v-if="lyraError"
class="mb-4 rounded-lg border border-red-900 bg-red-950/40 px-4 py-3 flex items-center gap-2"
>
<UIcon name="heroicons:exclamation-triangle" class="h-4 w-4 text-red-400 shrink-0" />
<p class="text-sm text-red-300">{{ lyraError }}</p>
</div>
<!-- Post Button -->
<UButton
block
:loading="lyraPosting"
:disabled="!lyraContent.trim() || lyraGenerating"
icon="heroicons:paper-airplane"
@click="postAsBot"
>
Als {{ currentAuthorNickname }} posten
</UButton>
<!-- Success -->
<div
v-if="lyraSuccess"
class="mt-4 rounded-lg border border-emerald-900 bg-emerald-950/40 p-4 flex items-start gap-3"
>
<UIcon name="heroicons:check-circle" class="h-5 w-5 text-emerald-400 shrink-0 mt-0.5" />
<div>
<p class="text-sm font-semibold text-emerald-300">Post veroeffentlicht</p>
<p class="text-xs text-gray-500 mt-0.5">
Erscheint sofort in der Community unter dem Profil von {{ currentAuthorNickname }}.
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: "admin-auth" });
const lyraTopics = [
{ value: "motivation", label: "Motivation", emoji: "💪" },
{ value: "tipp", label: "CBT-Tipp", emoji: "💡" },
{ value: "zitat", label: "Zitat", emoji: "📖" },
{ value: "witzig", label: "Witzig", emoji: "😄" },
{ value: "news", label: "News/Warnung", emoji: "⚠️" },
{ value: "feature", label: "Feature", emoji: "✨" },
] as const;
const author = ref<"lyra" | "rebreak">("lyra");
const lyraTopic = ref<string>("motivation");
const lyraContext = ref("");
const lyraContent = ref("");
const lyraGenerating = ref(false);
const lyraPosting = ref(false);
const lyraSuccess = ref(false);
const lyraError = ref("");
const lyraNickname = ref("Lyra");
const lyraAvatar = ref<string | null>(null);
const rebreakNickname = ref("ReBreak");
const rebreakAvatar = ref<string | null>(null);
const currentAuthorNickname = computed(() =>
author.value === "rebreak" ? rebreakNickname.value : lyraNickname.value,
);
const currentAuthorAvatar = computed(() =>
author.value === "rebreak" ? rebreakAvatar.value : lyraAvatar.value,
);
async function loadProfile(a: "lyra" | "rebreak") {
try {
const profile = await $fetch<{ nickname: string; avatar: string | null }>(
`/api/admin/lyra-profile`,
{ query: { author: a } },
);
if (a === "lyra") {
lyraNickname.value = profile.nickname;
lyraAvatar.value = profile.avatar;
} else {
rebreakNickname.value = profile.nickname;
rebreakAvatar.value = profile.avatar;
}
} catch {
// Fallback-Namen bleiben gesetzt
}
}
onMounted(async () => {
await Promise.all([loadProfile("lyra"), loadProfile("rebreak")]);
});
function selectAuthor(a: "lyra" | "rebreak") {
author.value = a;
lyraContent.value = "";
lyraSuccess.value = false;
lyraError.value = "";
}
async function generateContent() {
lyraError.value = "";
lyraSuccess.value = false;
lyraGenerating.value = true;
try {
const res = await $fetch<{ success: boolean; content: string }>(
"/api/admin/lyra-generate",
{
method: "POST",
body: {
author: author.value,
topic: lyraTopic.value,
context: lyraContext.value || undefined,
},
},
);
lyraContent.value = res.content;
} catch (err: any) {
lyraError.value =
err?.data?.statusMessage ?? err?.statusMessage ?? err?.message ?? "Fehler beim Generieren";
} finally {
lyraGenerating.value = false;
}
}
async function postAsBot() {
if (!lyraContent.value.trim()) return;
lyraError.value = "";
lyraSuccess.value = false;
lyraPosting.value = true;
try {
await $fetch("/api/admin/lyra-post", {
method: "POST",
body: {
author: author.value,
customContent: lyraContent.value.trim(),
},
});
lyraSuccess.value = true;
lyraContent.value = "";
lyraContext.value = "";
} catch (err: any) {
lyraError.value =
err?.data?.statusMessage ?? err?.statusMessage ?? err?.message ?? "Fehler beim Posten";
} finally {
lyraPosting.value = false;
}
}
</script>

View File

@ -0,0 +1,33 @@
// apps/admin/server/api/admin/lyra-generate.post.ts
//
// Proxy: leitet LLM-Generierungsrequest an das Backend weiter.
// Admin-Secret bleibt server-only (NIE im Client-Bundle).
export default defineEventHandler(async (event): Promise<{ success: boolean; content: string }> => {
const config = useRuntimeConfig();
const apiBase = config.public.apiBase;
const adminSecret = config.adminSecret;
if (!adminSecret) {
throw createError({
statusCode: 500,
statusMessage: "ADMIN_SECRET nicht konfiguriert (Infisical-Var fehlt)",
});
}
const body = await readBody(event).catch(() => ({}));
try {
return await $fetch(`${apiBase}/api/admin/lyra-generate`, {
method: "POST",
headers: { "x-admin-secret": adminSecret },
body: body ?? {},
});
} catch (err: any) {
throw createError({
statusCode: err?.statusCode ?? 502,
statusMessage:
err?.statusMessage ?? err?.message ?? "Backend-Request fehlgeschlagen",
});
}
});

View File

@ -0,0 +1,33 @@
// apps/admin/server/api/admin/lyra-post.post.ts
//
// Proxy: sendet manuellen Bot-Post an das Backend (generiert via LLM oder custom content).
// Admin-Secret bleibt server-only (NIE im Client-Bundle).
export default defineEventHandler(async (event): Promise<{ success: boolean; postId: string; author: string; content: string }> => {
const config = useRuntimeConfig();
const apiBase = config.public.apiBase;
const adminSecret = config.adminSecret;
if (!adminSecret) {
throw createError({
statusCode: 500,
statusMessage: "ADMIN_SECRET nicht konfiguriert (Infisical-Var fehlt)",
});
}
const body = await readBody(event).catch(() => ({}));
try {
return await $fetch(`${apiBase}/api/admin/lyra-post`, {
method: "POST",
headers: { "x-admin-secret": adminSecret },
body: body ?? {},
});
} catch (err: any) {
throw createError({
statusCode: err?.statusCode ?? 502,
statusMessage:
err?.statusMessage ?? err?.message ?? "Backend-Request fehlgeschlagen",
});
}
});

View File

@ -0,0 +1,33 @@
// apps/admin/server/api/admin/lyra-profile.get.ts
//
// Proxy: holt Nickname + Avatar des Lyra- bzw. ReBreak-Bot-Accounts.
// Admin-Secret bleibt server-only (NIE im Client-Bundle).
export default defineEventHandler(async (event): Promise<{ nickname: string; avatar: string | null }> => {
const config = useRuntimeConfig();
const apiBase = config.public.apiBase;
const adminSecret = config.adminSecret;
if (!adminSecret) {
throw createError({
statusCode: 500,
statusMessage: "ADMIN_SECRET nicht konfiguriert (Infisical-Var fehlt)",
});
}
const query = getQuery(event);
try {
return await $fetch(`${apiBase}/api/admin/lyra-profile`, {
method: "GET",
headers: { "x-admin-secret": adminSecret },
query,
});
} catch (err: any) {
throw createError({
statusCode: err?.statusCode ?? 502,
statusMessage:
err?.statusMessage ?? err?.message ?? "Backend-Request fehlgeschlagen",
});
}
});

View File

@ -0,0 +1,33 @@
// apps/admin/server/api/admin/set-lyra-avatar.post.ts
//
// Proxy: leitet Avatar-Upload (base64 PNG) an das Backend weiter.
// Admin-Secret bleibt server-only (NIE im Client-Bundle).
export default defineEventHandler(async (event): Promise<{ success: boolean; avatar: string }> => {
const config = useRuntimeConfig();
const apiBase = config.public.apiBase;
const adminSecret = config.adminSecret;
if (!adminSecret) {
throw createError({
statusCode: 500,
statusMessage: "ADMIN_SECRET nicht konfiguriert (Infisical-Var fehlt)",
});
}
const body = await readBody(event).catch(() => ({}));
try {
return await $fetch(`${apiBase}/api/admin/set-lyra-avatar`, {
method: "POST",
headers: { "x-admin-secret": adminSecret },
body: body ?? {},
});
} catch (err: any) {
throw createError({
statusCode: err?.statusCode ?? 502,
statusMessage:
err?.statusMessage ?? err?.message ?? "Backend-Request fehlgeschlagen",
});
}
});

View File

@ -0,0 +1,53 @@
@import "tailwindcss";
@import "@nuxt/ui";
@theme static {
--font-sans: 'Nunito', sans-serif;
}
/* Smooth Scrolling */
html {
scroll-behavior: smooth;
}
/* iOS Zoom-Fix: 16px verhindert Auto-Zoom bei Input-Fokus */
input,
select,
textarea {
font-size: 16px !important;
}
/* Verhindert Double-Tap-Zoom auf Buttons und interaktiven Elementen */
button, a, [role="button"] {
touch-action: manipulation;
}
/*
PAGE TRANSITIONS iOS-style slide
*/
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: opacity 200ms ease, transform 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
will-change: transform, opacity;
}
.slide-left-enter-from {
opacity: 0;
transform: translateX(20px);
}
.slide-left-leave-to {
opacity: 0;
transform: translateX(-20px);
}
.slide-right-enter-from {
opacity: 0;
transform: translateX(-20px);
}
.slide-right-leave-to {
opacity: 0;
transform: translateX(20px);
}

View File

@ -0,0 +1,57 @@
<template>
<span ref="spanEl">{{ display }}</span>
</template>
<script setup lang="ts">
/**
* AnimatedCounter zählt von 0 auf einen Zielwert hoch.
* Verwendet requestAnimationFrame mit Ease-out-Cubic.
*
* Props:
* target Zielwert (required)
* duration Animationsdauer in ms (default 1800)
* delay Startverzögerung in ms (default 0)
* decimals Nachkommastellen (default 0)
*/
const props = withDefaults(defineProps<{
target: number;
duration?: number;
delay?: number;
decimals?: number;
}>(), {
duration: 1800,
delay: 0,
decimals: 0,
});
const display = ref(props.decimals > 0 ? (0).toFixed(props.decimals) : '0');
const spanEl = ref<HTMLElement | null>(null);
let started = false;
function runAnimation() {
const startTime = performance.now();
const update = (now: number) => {
const elapsed = now - startTime;
const progress = Math.min(elapsed / props.duration, 1);
const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
const current = eased * props.target;
display.value = props.decimals > 0
? current.toFixed(props.decimals)
: Math.round(current).toString();
if (progress < 1) requestAnimationFrame(update);
};
requestAnimationFrame(update);
}
onMounted(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && !started) {
started = true;
observer.disconnect();
setTimeout(runAnimation, props.delay);
}
}, { threshold: 0.3 });
if (spanEl.value) observer.observe(spanEl.value);
onUnmounted(() => observer.disconnect());
});
</script>

View File

@ -0,0 +1,26 @@
<template>
<div class="rounded-2xl border border-default p-6" :class="bg">
<div class="flex items-start justify-between mb-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center" :class="bg.replace('/30', '/50')">
<UIcon :name="icon" :class="[color, 'text-xl']" />
</div>
<span class="text-xs font-bold px-2 py-1 rounded-full"
:class="badge === 'Premium' ? 'bg-primary-900/60 text-primary-300' : 'bg-green-900/60 text-green-300'">
{{ badge }}
</span>
</div>
<h3 class="font-bold text-highlighted mb-2">{{ title }}</h3>
<p class="text-sm text-muted leading-relaxed">{{ desc }}</p>
</div>
</template>
<script setup lang="ts">
defineProps<{
icon: string;
color: string;
bg: string;
badge: string;
title: string;
desc: string;
}>();
</script>

View File

@ -0,0 +1,111 @@
<template>
<ClientOnly>
<div class="w-full" :style="{ height: `${height}px` }">
<Line :data="chartData" :options="chartOptions" />
</div>
<template #fallback>
<div :style="{ height: `${height}px` }" class="animate-pulse bg-muted rounded-xl" />
</template>
</ClientOnly>
</template>
<script setup lang="ts">
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
type ChartData,
type ChartOptions,
} from "chart.js";
import { Line } from "vue-chartjs";
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler, Tooltip);
const props = defineProps<{
data: { label: string; count: number }[];
height?: number;
}>();
const height = computed(() => props.height ?? 180);
function resolvePrimaryRgb(): string {
if (typeof window === "undefined") return "rgb(14,165,233)";
const raw = getComputedStyle(document.documentElement).getPropertyValue("--ui-primary").trim();
const canvas = document.createElement("canvas");
canvas.width = 1; canvas.height = 1;
const ctx2 = canvas.getContext("2d");
if (!ctx2) return "rgb(14,165,233)";
ctx2.fillStyle = raw;
ctx2.fillRect(0, 0, 1, 1);
const [r, g, b] = ctx2.getImageData(0, 0, 1, 1).data;
return `rgb(${r},${g},${b})`;
}
const primaryRgb = ref("rgb(14,165,233)");
onMounted(() => { primaryRgb.value = resolvePrimaryRgb(); });
function rgba(alpha: number) {
return primaryRgb.value.replace("rgb(", "rgba(").replace(")", `,${alpha})`);
}
const chartData = computed<ChartData<"line">>(() => ({
labels: props.data.map((d) => d.label),
datasets: [
{
label: "Domains blockiert",
data: props.data.map((d) => d.count),
borderColor: primaryRgb.value,
backgroundColor: (ctx: any) => {
const chart = ctx.chart;
const { ctx: c, chartArea } = chart;
if (!chartArea) return rgba(0.3);
const grad = c.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
grad.addColorStop(0, rgba(0.35));
grad.addColorStop(1, rgba(0));
return grad;
},
fill: true,
cubicInterpolationMode: "monotone" as any,
pointRadius: 0,
borderWidth: 2,
tension: 0.4,
},
],
}));
const chartOptions = computed<ChartOptions<"line">>(() => ({
responsive: true,
maintainAspectRatio: false,
animation: { duration: 600, easing: "easeOutExpo" } as any,
interaction: { intersect: false, mode: "index" },
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => ` ${(ctx.parsed?.y ?? 0).toLocaleString("de-DE")} Domains`,
},
},
},
scales: {
x: {
grid: { display: false },
border: { display: false },
ticks: { color: "#6b7280", font: { size: 11 }, maxTicksLimit: 6, maxRotation: 0 },
},
y: {
grid: { color: "rgba(255,255,255,0.04)" },
border: { display: false },
ticks: {
color: "#6b7280",
font: { size: 11 },
maxTicksLimit: 4,
callback: (v: any) => (v >= 1000 ? `${Math.round(v / 1000)}k` : v),
},
},
},
}));
</script>

View File

@ -0,0 +1,18 @@
/**
* Reactive viewport height.
* Vereinfachte Version für Marketing-Site (kein Capacitor/WKWebView-Keyboard-Handling nötig).
*/
export function useViewportHeight() {
const height = ref(globalThis.innerHeight || 800);
onMounted(() => {
const update = () => {
height.value = window.innerHeight;
};
window.addEventListener("resize", update);
update();
onUnmounted(() => window.removeEventListener("resize", update));
});
return { height };
}

View File

@ -0,0 +1,95 @@
<template>
<div data-layout="default" class="flex flex-col overflow-hidden bg-default text-highlighted"
:style="{ height: vpHeight + 'px' }">
<!-- Header sticky, innerhalb des Flex-Flows -->
<header class="shrink-0 h-16 border-b border-default bg-default/95 backdrop-blur-md">
<div class="max-w-6xl mx-auto px-4 sm:px-6 h-full flex items-center justify-between">
<NuxtLink to="/" class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
<UIcon name="i-heroicons-shield-check" class="text-white" />
</div>
<span class="font-bold text-lg text-highlighted">ReBreak</span>
</NuxtLink>
<nav class="hidden sm:flex items-center gap-6 text-sm text-muted">
<NuxtLink to="/pricing" class="hover:text-highlighted transition-colors">{{ $t("nav.pricing") }}</NuxtLink>
<NuxtLink to="/resources" class="hover:text-highlighted transition-colors">{{ $t("nav.resources") }}</NuxtLink>
</nav>
<!-- App-Download CTA statt Login (Marketing hat keine Auth) -->
<div class="flex items-center gap-3">
<a href="https://apps.apple.com/app/rebreak" target="_blank" rel="noopener">
<UButton size="sm" color="primary">{{ $t("nav.download_app") }}</UButton>
</a>
</div>
</div>
</header>
<!-- Scrollbarer Inhalt -->
<div class="flex-1 overflow-y-auto">
<slot />
<!-- DSGVO-Pflicht: Datenschutz + Impressum public erreichbar -->
<footer class="border-t border-default mt-12 pb-24 md:pb-6 pt-6 px-4">
<div
class="max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-muted">
<p>© {{ new Date().getFullYear() }} Rebreak</p>
<nav class="flex flex-wrap items-center gap-x-5 gap-y-2">
<NuxtLink to="/datenschutz" class="hover:text-primary-400 transition-colors">Datenschutz</NuxtLink>
<NuxtLink to="/impressum" class="hover:text-primary-400 transition-colors">Impressum</NuxtLink>
<NuxtLink to="/nutzungsbedingungen" class="hover:text-primary-400 transition-colors">
Nutzungsbedingungen
</NuxtLink>
</nav>
</div>
</footer>
</div>
<!-- Floating Pill Tab-Bar (Mobile) -->
<div class="fixed bottom-3 left-4 right-4 z-50 pointer-events-none">
<nav
class="pointer-events-auto max-w-lg mx-auto bg-white/80 dark:bg-elevated backdrop-blur-md rounded-4xl shadow-[0_4px_24px_rgba(0,0,0,0.12)] dark:shadow-[0_4px_24px_rgba(0,0,0,0.3)] ring-1 ring-black/10 dark:ring-white/10 px-2 py-1.5">
<div class="flex items-center">
<NuxtLink v-for="tab in tabs" :key="tab.to" :to="tab.to"
class="relative flex-1 flex flex-col items-center gap-0.5 px-1 py-1.5 rounded-3xl transition-colors"
:class="isActive(tab.to) ? 'text-primary-400' : 'text-muted'">
<UIcon :name="isActive(tab.to) ? tab.iconActive : tab.icon" class="size-6 shrink-0" />
<span class="text-[10px] leading-none whitespace-nowrap font-semibold">{{ tab.label }}</span>
</NuxtLink>
</div>
</nav>
</div>
</div>
</template>
<script setup lang="ts">
const { height: vpHeight } = useViewportHeight();
const route = useRoute();
const { t } = useI18n();
const tabs = computed(() => [
{
to: "/",
icon: "i-heroicons-home",
iconActive: "i-heroicons-home-solid",
label: t('pricing.footer_home'),
},
{
to: "/pricing",
icon: "i-heroicons-credit-card",
iconActive: "i-heroicons-credit-card-solid",
label: t('pricing.footer_pricing'),
},
{
to: "/resources",
icon: "i-heroicons-book-open",
iconActive: "i-heroicons-book-open-solid",
label: t('pricing.footer_resources'),
},
]);
function isActive(to: string) {
if (to === "/") return route.path === "/";
return route.path.startsWith(to);
}
</script>

View File

@ -0,0 +1,230 @@
{
"nav": {
"pricing": "Preise",
"resources": "Hilfe",
"login": "Einloggen",
"download_app": "App laden"
},
"landing": {
"hero_badge": "Gemeinsam gegen die Gambling-Industrie",
"hero_title": "Millionen kämpfen still.",
"hero_subtitle": "Du musst das nicht allein tun!",
"hero_text": "Gemeinsam sind wir Stark!",
"cta_start": "Jetzt kostenlos starten",
"stat_affected": "Menschen in DE betroffen",
"stat_blocked": "Domains geblockt",
"stat_free": "Zum Starten",
"more_info": "Mehr erfahren",
"blocker_badge": "Gambling Blocker",
"blocker_title_domains": "Domains.",
"blocker_title_activated": "Einmal aktiviert.",
"blocker_desc": "Die umfangreichste Gambling-Blocklist. Täglich aktualisiert. Für alle Plattformen. Ein Cooldown verhindert schwache Momente.",
"blocker_feat_platforms": "Für macOS, iOS, Android & Pi-hole",
"blocker_feat_updated": "Täglich aktualisierte Liste",
"blocker_feat_custom": "Eigene Domains hinzufügen",
"blocker_feat_cooldown": "Cooldown-Schutz vor Rückfällen",
"oasis_badge": "Warum OASIS allein nicht reicht",
"oasis_title": "Täglich neue Casinos ",
"oasis_subtitle": "ohne Lizenz, ohne Sperre.",
"oasis_desc": "Der OASIS-Selbstausschluss sperrt dich nur bei lizenzierten Anbietern. Doch täglich gehen neue Casino-Seiten online viele ohne Lizenz, viele offshore. Diese Seiten kennen OASIS nicht. ReBreak schützt dich auch dort: mit einer täglich aktualisierten Datenbank von über 208.000 Domains.",
"oasis_new_domains": "neue Gambling-Domains täglich",
"oasis_offshore": "Casinos ohne Lizenz umgehen OASIS komplett",
"oasis_updated": "Domains täglich aktualisiert durch ReBreak",
"streak_badge": "Streak & Ersparnisse",
"streak_title": "Jeden Tag zählt.",
"streak_subtitle": "Sichtbarer Fortschritt.",
"streak_desc": "Sieh wie viele Tage du gewonnen hast und wie viel Geld du nicht verloren hast. Meilenstein-Badges motivieren weiter.",
"streak_days_free": "Tage frei",
"streak_saved": "gespart",
"crisis_badge": "Krisenmomente meistern",
"crisis_title": "Der Drang kommt.",
"crisis_subtitle": "Du bist vorbereitet.",
"sos_title": "SOS Sofort-Hilfe",
"sos_subtitle": "Ein Klick. Sofort.",
"sos_desc": "Der Drang dauert im Schnitt nur 1520 Minuten. ReBreak führt dich Schritt für Schritt durch diesen Moment bis er vorüber ist.",
"sos_angry": "Wütend",
"sos_sad": "Niedergedrückt",
"sos_stressed": "Gestresst",
"sos_empty": "Leer",
"breathing_title": "4-7-8 Atemübung",
"breathing_subtitle": "Puls senken in 60 Sekunden",
"breathing_desc": "Wissenschaftlich belegt: 4 Sekunden einatmen, 7 halten, 8 ausatmen der Körper schaltet automatisch in den Ruhemodus.",
"breathing_breathe": "Atme",
"breathing_inhale": "4s einatmen",
"breathing_hold": "7s halten",
"breathing_exhale": "8s ausatmen",
"coach_badge": "Wenn SOS nicht reicht",
"coach_title": "Coach & Community.",
"coach_subtitle": "Immer auf Abruf.",
"coach_desc": "Ein KI-Coach, der dich wirklich kennt personalisiert, CBT-basiert, ohne Urteil. Und eine echte Community aus Menschen, die verstehen was du durchmachst.",
"coach_label": "KI-Coach",
"founding_badge": "Gründungsmitglied",
"founding_desc": "Die ersten {count} Mitglieder bekommen 1 Monat Standard gratis automatisch, kein Code nötig.",
"founding_slots": "{current} / {total} Plätze",
"founding_cta": "Jetzt Platz sichern kostenlos",
"mail_badge": "Mail-Bereinigung",
"mail_title": "Bonus-Mails?",
"mail_subtitle": "Nie gesehen.",
"mail_desc": "Casinos bombardieren dich täglich mit Angeboten und Rabatten. ReBreak verbindet sich mit deinem Postfach und verschiebt diese Mails in den Papierkorb bevor du sie siehst.",
"mail_feat_providers": "Gmail, GMX, Outlook alle großen Anbieter",
"mail_feat_intervals": "Echtzeit, stündlich oder alle 4 Stunden",
"mail_feat_privacy": "Keine Mail wird gelesen nur analysiert",
"mail_mock_blocked": "Blockiert",
"mail_mock_scanned": "Gescannt",
"mail_mock_rate": "Treffer",
"mail_mock_accounts": "Verbundene Konten",
"mail_mock_rhythm": "Automatischer Scan-Rhythmus",
"final_title": "Fang jetzt an.",
"final_desc": "Du bist nicht kaputt. Das System ist manipulativ. Wir helfen dir zurück.",
"final_cta": "Jetzt starten kostenlos & anonym",
"chat_msg_1": "Ich spüre den Drang wieder stark...",
"chat_msg_2": "Ich verstehe. Was triggert dich gerade? Lass uns das durchgehen.",
"chat_msg_3": "Stress bei der Arbeit.",
"chat_msg_4": "Das ist ein bekanntes Muster. Probier erst die 4-7-8 Übung."
},
"blocked": {
"lyra": "Lyra",
"title": "Diese Seite ist blockiert",
"message": "ReBreak hat diese Seite für dich gesperrt. Du hast dich entschieden, stark zu sein und das hier ist der Beweis.",
"day": "Tag",
"days": "Tage",
"clean": "clean",
"streak_running": "Dein Streak läuft. Gib ihn nicht auf.",
"talk_lyra": "Mit Lyra reden",
"start_breathing": "Atemübung starten",
"back_to_app": "Zurück zur App",
"quote_1": "Jede blockierte Seite ist ein Beweis deiner Stärke.",
"quote_2": "Der Drang geht vorbei. Dein Fortschritt bleibt.",
"quote_3": "Du hast diese Seite nicht gebraucht und du brauchst sie nicht.",
"quote_4": "Stark sein bedeutet, in diesem Moment Nein zu sagen.",
"quote_5": "Das hier ist dein Schutzwall. Du hast ihn aufgebaut."
},
"resources": {
"blocklist_title": "Community-Blocklist",
"blocklist_desc": "Wächst täglich von der Community, für die Community. Aktuell {count} Domains blockiert.",
"chart_label": "Blockierte Domains letzten 12 Monate",
"hotlines_title": "Sofort-Hilfe & Hotlines",
"hotlines_desc": "Kostenlos, anonym, rund um die Uhr erreichbar.",
"tips_title": "Was jetzt hilft",
"tips_desc": "Bewährte Strategien aus der kognitiven Verhaltenstherapie (CBT).",
"not_weak_title": "Du bist nicht schwach",
"not_weak_desc": "Das System ist darauf ausgelegt. Hier ist warum.",
"cta_title": "Bereit für den ersten Schritt?",
"cta_button": "App herunterladen",
"hotline_de": "Deutschland",
"hotline_at": "Österreich",
"hotline_ch": "Schweiz",
"tip_breathing": "4-7-8 Atemübung bei akutem Drang",
"tip_breathing_desc": "4 Sek. einatmen, 7 halten, 8 ausatmen. Aktiviert das parasympathische Nervensystem und bricht den Impulsdrang.",
"tip_15min": "Die 15-Minuten-Regel",
"tip_15min_desc": "Warte 15 Minuten bevor du eine Entscheidung triffst. Gambling-Drang ist eine Welle sie kommt und geht.",
"tip_move": "Raus und bewegen",
"tip_move_desc": "Ein 10-minütiger Spaziergang setzt Endorphine frei und unterbricht automatisch den Drang-Kreislauf.",
"tip_triggers": "Trigger kennen",
"tip_triggers_desc": "Stress, Langeweile, Abend allein? Wer seine Muster kennt, kann gegensteuern bevor der Drang überwältigt.",
"fact1_title": "Variable Belohnungen aktivieren denselben Kreislauf wie Drogen",
"fact1_text": "Das Nicht-Wissen, ob man gewinnt, schüttet mehr Dopamin aus als ein sicherer Gewinn. Design, kein Zufall.",
"fact2_title": "Online-Casinos sind 24/7 verfügbar kein natürlicher Stopper",
"fact2_text": "Früher war das Casino physisch. Heute ist es das Handy. Kein Schließtag, keine Scham durch andere.",
"fact3_title": "Virtuelle Währungen verschleiern echten Geldverlust",
"fact3_text": "Chips, Coins, Credits das Gehirn verarbeitet diese nicht wie Bargeld. Das ist kein Fehler im System.",
"fact4_title": "Die Quote gewinnt immer mathematisch",
"fact4_text": "Jedes legale Casino hat eingebaute Marge. Langfristig verlieren 100 % der Spieler Geld. Keine Pechsträhne."
},
"pricing": {
"founding_banner": "Founding Member Die ersten 100 bekommen 3 Monate Legend gratis",
"title": "Dein Weg, dein Tempo",
"subtitle_start": "Jetzt starten ",
"subtitle_end": "wähle deinen Plan.",
"pro_meaning_title": "Was bedeutet Pro wirklich?",
"pro_meaning_desc": "Mit Pro trägst du aktiv dazu bei, dass die ReBreak Blocklist für alle wächst. Du kannst Domains direkt hinzufügen und Einreichungen anderer Nutzer prüfen. Du leitest Gruppen, hast keinen KI-Gedächtnisverlust und stehst an der Spitze für alle, die noch kämpfen.",
"comparison_title": "Was ist inklusive?",
"comparison_subtitle": "Vollständiger Vergleich aller Pläne",
"feature": "Feature",
"free": "Kostenlos",
"quotes_title": "Gedanken die helfen",
"quotes_subtitle": "Von Psychologen und Denkern über Selbstschutz und Veränderung",
"faq_title": "Häufige Fragen",
"cta_title": "Bereit anzufangen?",
"cta_desc": "Kostenlos starten, jederzeit upgraden.",
"cta_button": "App herunterladen",
"footer_home": "Home",
"footer_pricing": "Preise",
"footer_resources": "Ressourcen",
"footer_login": "Anmelden",
"billing_monthly": "Monatlich",
"billing_yearly": "Jährlich",
"billing_save_pct": "Spare 39%",
"billing_forever": "für immer",
"billing_per_month": "/ Monat",
"billing_per_year": "/ Monat, jährlich",
"plan_free_title": "Kostenlos",
"plan_free_desc": "Einstieg ohne Risiko für immer gratis.",
"plan_free_btn": "App herunterladen",
"plan_pro_title": "Pro",
"plan_pro_desc": "Vollständiger Schutz und alle Tools für deinen Alltag.",
"plan_pro_btn": "Pro starten",
"plan_legend_title": "Legend",
"plan_legend_desc": "Für die, die stark genug sind um anderen den Weg zu ebnen.",
"plan_legend_btn": "Legend starten",
"plan_loading": "Wird geladen...",
"plan_recommended": "Empfohlen",
"feat_free_domains": "5 eigene Domains",
"feat_free_mail": "1 Mail-Agent (Scan alle 4h)",
"feat_coach_basic": "KI-Coach Basis",
"feat_streak": "Streak & Ersparnisse Tracker",
"feat_urge": "Urge Tracker + Atemübung",
"feat_sos": "SOS-Button (Sofort-Hilfe)",
"feat_community": "Gemeinschaft erleben",
"feat_all_free": "Alles aus Kostenlos",
"feat_blocklist": "ReBreak Blocklist (208k+ Domains)",
"feat_pro_domains": "5 eigene Domains (rückfüllbar)",
"feat_pro_mail": "3 Mail-Agenten (Intervall: 1h / 4h / 8h)",
"feat_community_post": "Community posten",
"feat_buddy": "Buddy System",
"feat_coach_pro": "KI-Coach (besser)",
"feat_urge_stats": "Urge-Statistiken & Muster",
"feat_all_pro": "Alles aus Pro",
"feat_legend_domains": "Unbegrenzte eigene Domains (rückfüllbar)",
"feat_legend_mail": "Unbegrenzte Mail-Agenten (Echtzeit)",
"feat_legend_add": "Domains direkt zur ReBreak Blocklist hinzufügen",
"feat_legend_validate": "Community-Domains validieren",
"feat_legend_groups": "Gruppen gründen & leiten",
"feat_coach_legend": "Top KI-Coach mit Gedächtnis",
"comp_domains": "Eigene Domains",
"comp_mail": "Mail-Agent",
"comp_coach": "KI-Coach",
"comp_streak": "Streak & Ersparnisse Tracker",
"comp_urge": "Urge Tracker + Atemübung",
"comp_sos": "SOS-Button (Sofort-Hilfe)",
"comp_community": "Gemeinschaft erleben",
"comp_blocklist": "ReBreak Blocklist (208k+ Domains)",
"comp_post": "Community posten",
"comp_buddy": "Buddy System",
"comp_urge_stats": "Urge-Statistiken & Muster",
"comp_add_domain": "Domains zur Blocklist hinzufügen",
"comp_validate": "Community-Domains validieren",
"comp_groups": "Gruppen gründen & leiten",
"comp_free_domains": "5",
"comp_pro_domains": "5 (rückfüllbar)",
"comp_legend_domains": "Unbegrenzt (rückfüllbar)",
"comp_free_mail_val": "1 (4h)",
"comp_pro_mail_val": "3 (1h / 4h / 8h)",
"comp_legend_mail_val": "Echtzeit",
"comp_free_coach_val": "Basis",
"comp_pro_coach_val": "Besser",
"comp_legend_coach_val": "Top + Gedächtnis",
"faq1_q": "Muss ich eine E-Mail-Adresse angeben?",
"faq1_a": "Ja, für die Registrierung wird eine E-Mail-Adresse benötigt. Deine Daten werden ausschließlich auf deutschen Servern gespeichert und verarbeitet vollständig anonym, nach strengen DSGVO-Standards. Kein Name, kein Standort, kein Nutzungsverhalten wird an Dritte weitergegeben.",
"faq2_q": "Was ist der Unterschied zwischen Pro und Legend?",
"faq2_a": "Pro gibt dir vollständigen Schutz: ReBreak Blocklist (208k+ Domains), 3 Mail-Agenten, KI-Coach und Community. Legend geht weiter: unbegrenzte Domains und Agenten, direktes Hinzufügen zur Blocklist, Validierung von Community-Domains, Gruppen leiten und Top KI-Coach mit Gedächtnis.",
"faq3_q": "Welche Zahlungszyklen gibt es?",
"faq3_a": "Monatlich (voller Preis) oder jährlich (Spare 39%). Du kannst jederzeit wechseln.",
"faq4_q": "Kann ich jederzeit kündigen?",
"faq4_a": "Ja, du kannst dein Abo jederzeit kündigen. Du behältst den Zugang bis zum Ende der bezahlten Periode.",
"faq5_q": "Was passiert mit meinen Daten wenn ich kündige?",
"faq5_a": "Dein Account und alle Daten bleiben erhalten. Dein Streak und alle Tracker gehören dir für immer.",
"faq6_q": "Ist ReBreak ein Ersatz für professionelle Hilfe?",
"faq6_a": "Nein. ReBreak ist ein Selbsthilfe-Tool. Bei Krisen: BZgA (0800 1372700) oder Arzt aufsuchen."
}
}

View File

@ -0,0 +1,230 @@
{
"nav": {
"pricing": "Pricing",
"resources": "Help",
"login": "Login",
"download_app": "Get the App"
},
"landing": {
"hero_badge": "Together against the gambling industry",
"hero_title": "Millions fight in silence.",
"hero_subtitle": "You don't have to do it alone!",
"hero_text": "Together we are strong!",
"cta_start": "Start free now",
"stat_affected": "People in DE affected",
"stat_blocked": "Domains blocked",
"stat_free": "To start",
"more_info": "Learn more",
"blocker_badge": "Gambling Blocker",
"blocker_title_domains": "Domains.",
"blocker_title_activated": "Once activated.",
"blocker_desc": "The most comprehensive gambling blocklist. Updated daily. For all platforms. A cooldown prevents weak moments.",
"blocker_feat_platforms": "For macOS, iOS, Android & Pi-hole",
"blocker_feat_updated": "Daily updated list",
"blocker_feat_custom": "Add custom domains",
"blocker_feat_cooldown": "Cooldown protection against relapses",
"oasis_badge": "Why OASIS alone isn't enough",
"oasis_title": "New casinos daily ",
"oasis_subtitle": "without license, without ban.",
"oasis_desc": "The OASIS self-exclusion only blocks you at licensed providers. But new casino sites go online daily many without a license, many offshore. These sites don't know OASIS. ReBreak protects you there too: with a daily updated database of over 208,000 domains.",
"oasis_new_domains": "new gambling domains daily",
"oasis_offshore": "Casinos without license bypass OASIS completely",
"oasis_updated": "Domains updated daily by ReBreak",
"streak_badge": "Streak & Savings",
"streak_title": "Every day counts.",
"streak_subtitle": "Visible progress.",
"streak_desc": "See how many days you've won and how much money you haven't lost. Milestone badges keep you motivated.",
"streak_days_free": "Days free",
"streak_saved": "saved",
"crisis_badge": "Mastering crisis moments",
"crisis_title": "The urge comes.",
"crisis_subtitle": "You are prepared.",
"sos_title": "SOS Instant Help",
"sos_subtitle": "One click. Instant.",
"sos_desc": "The urge lasts on average only 1520 minutes. ReBreak guides you step by step through this moment until it passes.",
"sos_angry": "Angry",
"sos_sad": "Depressed",
"sos_stressed": "Stressed",
"sos_empty": "Empty",
"breathing_title": "4-7-8 Breathing Exercise",
"breathing_subtitle": "Lower pulse in 60 seconds",
"breathing_desc": "Scientifically proven: breathe in for 4 seconds, hold for 7, breathe out for 8 the body automatically switches to rest mode.",
"breathing_breathe": "Breathe",
"breathing_inhale": "4s inhale",
"breathing_hold": "7s hold",
"breathing_exhale": "8s exhale",
"coach_badge": "When SOS isn't enough",
"coach_title": "Coach & Community.",
"coach_subtitle": "Always on call.",
"coach_desc": "An AI coach that truly knows you personalized, CBT-based, without judgment. And a real community of people who understand what you're going through.",
"coach_label": "AI Coach",
"founding_badge": "Founding Member",
"founding_desc": "The first {count} members get 1 month Standard free automatically, no code needed.",
"founding_slots": "{current} / {total} Spots",
"founding_cta": "Secure your spot free",
"mail_badge": "Mail Cleanup",
"mail_title": "Bonus emails?",
"mail_subtitle": "Never seen.",
"mail_desc": "Casinos bombard you daily with offers and discounts. ReBreak connects to your inbox and moves these emails to trash before you see them.",
"mail_feat_providers": "Gmail, GMX, Outlook all major providers",
"mail_feat_intervals": "Real-time, hourly or every 4 hours",
"mail_feat_privacy": "No email is read only analyzed",
"mail_mock_blocked": "Blocked",
"mail_mock_scanned": "Scanned",
"mail_mock_rate": "Hit rate",
"mail_mock_accounts": "Connected accounts",
"mail_mock_rhythm": "Automatic scan rhythm",
"final_title": "Start now.",
"final_desc": "You're not broken. The system is manipulative. We help you back.",
"final_cta": "Start now free & anonymous",
"chat_msg_1": "I feel the urge strongly again...",
"chat_msg_2": "I understand. What's triggering you right now? Let's go through this.",
"chat_msg_3": "Stress at work.",
"chat_msg_4": "That's a known pattern. Try the 4-7-8 exercise first."
},
"blocked": {
"lyra": "Lyra",
"title": "This site is blocked",
"message": "ReBreak blocked this site for you. You chose to be strong and this is the proof.",
"day": "Day",
"days": "Days",
"clean": "clean",
"streak_running": "Your streak is running. Don't give it up.",
"talk_lyra": "Talk to Lyra",
"start_breathing": "Start breathing exercise",
"back_to_app": "Back to app",
"quote_1": "Every blocked site is proof of your strength.",
"quote_2": "The urge passes. Your progress stays.",
"quote_3": "You didn't need this site and you don't need it.",
"quote_4": "Being strong means saying no in this moment.",
"quote_5": "This is your wall of protection. You built it."
},
"resources": {
"blocklist_title": "Community Blocklist",
"blocklist_desc": "Growing daily by the community, for the community. Currently {count} domains blocked.",
"chart_label": "Blocked domains last 12 months",
"hotlines_title": "Instant Help & Hotlines",
"hotlines_desc": "Free, anonymous, available 24/7.",
"tips_title": "What helps now",
"tips_desc": "Proven strategies from cognitive behavioral therapy (CBT).",
"not_weak_title": "You are not weak",
"not_weak_desc": "The system is designed this way. Here's why.",
"cta_title": "Ready for the first step?",
"cta_button": "Download the App",
"hotline_de": "Germany",
"hotline_at": "Austria",
"hotline_ch": "Switzerland",
"tip_breathing": "4-7-8 breathing exercise for acute urges",
"tip_breathing_desc": "Inhale 4 sec, hold 7, exhale 8. Activates the parasympathetic nervous system and breaks the impulse.",
"tip_15min": "The 15-minute rule",
"tip_15min_desc": "Wait 15 minutes before making a decision. Gambling urge is a wave it comes and goes.",
"tip_move": "Get out and move",
"tip_move_desc": "A 10-minute walk releases endorphins and automatically interrupts the urge cycle.",
"tip_triggers": "Know your triggers",
"tip_triggers_desc": "Stress, boredom, evening alone? Those who know their patterns can counteract before the urge overwhelms.",
"fact1_title": "Variable rewards activate the same circuit as drugs",
"fact1_text": "Not knowing if you'll win releases more dopamine than a certain win. Design, not accident.",
"fact2_title": "Online casinos are available 24/7 no natural stopper",
"fact2_text": "The casino used to be physical. Today it's your phone. No closing day, no shame from others.",
"fact3_title": "Virtual currencies obscure real money loss",
"fact3_text": "Chips, coins, credits the brain doesn't process these like cash. That's not a bug in the system.",
"fact4_title": "The house always wins mathematically",
"fact4_text": "Every legal casino has a built-in margin. Long-term, 100% of players lose money. No bad luck streak."
},
"pricing": {
"founding_banner": "Founding Member First 100 get 3 months Legend free",
"title": "Your path, your pace",
"subtitle_start": "Start now ",
"subtitle_end": "choose your plan.",
"pro_meaning_title": "What does Pro really mean?",
"pro_meaning_desc": "With Pro you actively contribute to growing the ReBreak blocklist for everyone. You can add domains directly and review submissions from other users. You lead groups, have no AI memory loss and stand at the forefront for everyone still fighting.",
"comparison_title": "What's included?",
"comparison_subtitle": "Complete comparison of all plans",
"feature": "Feature",
"free": "Free",
"quotes_title": "Thoughts that help",
"quotes_subtitle": "From psychologists and thinkers on self-protection and change",
"faq_title": "Frequently Asked Questions",
"cta_title": "Ready to start?",
"cta_desc": "Start free, upgrade anytime.",
"cta_button": "Download the App",
"footer_home": "Home",
"footer_pricing": "Pricing",
"footer_resources": "Resources",
"footer_login": "Login",
"billing_monthly": "Monthly",
"billing_yearly": "Yearly",
"billing_save_pct": "Save 39%",
"billing_forever": "forever",
"billing_per_month": "/ month",
"billing_per_year": "/ month, billed yearly",
"plan_free_title": "Free",
"plan_free_desc": "Get started with no risk free forever.",
"plan_free_btn": "Download App",
"plan_pro_title": "Pro",
"plan_pro_desc": "Full protection and all tools for your daily life.",
"plan_pro_btn": "Start Pro",
"plan_legend_title": "Legend",
"plan_legend_desc": "For those strong enough to light the way for others.",
"plan_legend_btn": "Start Legend",
"plan_loading": "Loading...",
"plan_recommended": "Recommended",
"feat_free_domains": "5 custom domains",
"feat_free_mail": "1 mail agent (scan every 4h)",
"feat_coach_basic": "AI Coach Basic",
"feat_streak": "Streak & Savings Tracker",
"feat_urge": "Urge Tracker + Breathing Exercise",
"feat_sos": "SOS Button (Instant Help)",
"feat_community": "Experience the community",
"feat_all_free": "Everything in Free",
"feat_blocklist": "ReBreak Blocklist (208k+ domains)",
"feat_pro_domains": "5 custom domains (refillable)",
"feat_pro_mail": "3 mail agents (interval: 1h / 4h / 8h)",
"feat_community_post": "Post in community",
"feat_buddy": "Buddy System",
"feat_coach_pro": "AI Coach (Better)",
"feat_urge_stats": "Urge statistics & patterns",
"feat_all_pro": "Everything in Pro",
"feat_legend_domains": "Unlimited custom domains (refillable)",
"feat_legend_mail": "Unlimited mail agents (real-time)",
"feat_legend_add": "Add domains directly to the ReBreak Blocklist",
"feat_legend_validate": "Validate community domains",
"feat_legend_groups": "Create & lead groups",
"feat_coach_legend": "Top AI Coach with memory",
"comp_domains": "Custom Domains",
"comp_mail": "Mail Agent",
"comp_coach": "AI Coach",
"comp_streak": "Streak & Savings Tracker",
"comp_urge": "Urge Tracker + Breathing",
"comp_sos": "SOS Button (Instant Help)",
"comp_community": "Experience community",
"comp_blocklist": "ReBreak Blocklist (208k+ domains)",
"comp_post": "Post in community",
"comp_buddy": "Buddy System",
"comp_urge_stats": "Urge statistics & patterns",
"comp_add_domain": "Add domains to blocklist",
"comp_validate": "Validate community domains",
"comp_groups": "Create & lead groups",
"comp_free_domains": "5",
"comp_pro_domains": "5 (refillable)",
"comp_legend_domains": "Unlimited (refillable)",
"comp_free_mail_val": "1 (4h)",
"comp_pro_mail_val": "3 (1h / 4h / 8h)",
"comp_legend_mail_val": "Real-time",
"comp_free_coach_val": "Basic",
"comp_pro_coach_val": "Better",
"comp_legend_coach_val": "Top + Memory",
"faq1_q": "Do I need to provide an email address?",
"faq1_a": "Yes, an email address is required for registration. Your data is stored and processed exclusively on German servers fully anonymously, according to strict GDPR standards.",
"faq2_q": "What's the difference between Pro and Legend?",
"faq2_a": "Pro gives you full protection: ReBreak Blocklist (208k+ domains), 3 mail agents, AI Coach and community. Legend goes further: unlimited domains, direct blocklist additions, domain validation, group leadership and top AI Coach with memory.",
"faq3_q": "What billing cycles are available?",
"faq3_a": "Monthly (full price) or yearly (save 39%). You can switch at any time.",
"faq4_q": "Can I cancel at any time?",
"faq4_a": "Yes, you can cancel your subscription at any time. You keep access until the end of the paid period.",
"faq5_q": "What happens to my data when I cancel?",
"faq5_a": "Your account and all data remain intact. Your streak and all trackers belong to you forever.",
"faq6_q": "Is ReBreak a substitute for professional help?",
"faq6_a": "No. ReBreak is a self-help tool. In crises: contact a professional or call a helpline."
}
}

View File

@ -0,0 +1,120 @@
<template>
<div class="min-h-screen bg-default font-sans pb-16 md:pb-0">
<!-- Header -->
<div class="pt-10 pb-8 text-center px-4">
<div class="inline-flex items-center gap-2 bg-primary-950/60 border border-primary-800/40 rounded-full px-4 py-1.5 text-sm text-primary-300 mb-6">
<UIcon name="i-heroicons-trash" class="text-primary-400" />
Konto-Löschung
</div>
<h1 class="text-3xl md:text-5xl font-extrabold text-highlighted mb-3">
Konto und Daten löschen
</h1>
<p class="text-muted text-sm">
Du hast jederzeit das Recht, dein Konto und alle zugehörigen Daten löschen zu lassen (Art. 17 DSGVO).
</p>
</div>
<!-- Content -->
<article class="px-4 max-w-3xl mx-auto pb-24 text-sm text-muted leading-relaxed space-y-8">
<!-- Methode 1: In-App -->
<section>
<h2 class="text-xl font-bold text-highlighted mb-3">Variante 1: In der App löschen (empfohlen)</h2>
<ol class="list-decimal list-inside space-y-2">
<li>Öffne die ReBreak-App auf deinem Gerät</li>
<li>Tippe auf das Profil-Icon oben rechts</li>
<li>Wähle <strong>Einstellungen</strong></li>
<li>Scrolle zu <strong>Konto</strong> und tippe auf <strong>Konto löschen</strong></li>
<li>Bestätige die Löschung alle Daten werden innerhalb von 30 Tagen unwiderruflich entfernt</li>
</ol>
</section>
<!-- Methode 2: Email -->
<section>
<h2 class="text-xl font-bold text-highlighted mb-3">Variante 2: Per E-Mail anfordern</h2>
<p class="mb-3">Falls du keinen Zugriff mehr auf die App hast, schreibe uns:</p>
<div class="bg-elevated border border-default rounded-xl p-4 not-prose">
<p>E-Mail: <a href="mailto:datenschutz@rebreak.org" class="text-primary-400 hover:underline">datenschutz@rebreak.org</a></p>
<p class="mt-2 text-xs">Betreff: <em>Konto-Löschung ReBreak"</em></p>
<p class="mt-2 text-xs">Bitte gib die mit deinem Konto verknüpfte E-Mail-Adresse an, damit wir dein Konto eindeutig identifizieren können.</p>
</div>
<p class="text-xs italic mt-3">Deine Anfrage wird innerhalb von 30 Tagen bearbeitet. Wir senden dir eine Bestätigung sobald die Löschung abgeschlossen ist.</p>
</section>
<!-- Was wird gelöscht -->
<section>
<h2 class="text-xl font-bold text-highlighted mb-3">Was wird gelöscht?</h2>
<ul class="list-disc list-inside space-y-1.5">
<li>Dein ReBreak-Konto inklusive E-Mail, Nickname und Avatar</li>
<li>Alle Streak-, Trigger- und Recovery-Daten</li>
<li>Lyra-KI-Coach Chat-Verlauf und Memories</li>
<li>Community-Posts und Kommentare</li>
<li>Mail-Schutz-Verbindungen und gespeicherte IMAP-Tokens</li>
<li>Custom-Domain-Listen</li>
<li>Geräte-Registrierungen</li>
<li>Demographische Profil-Angaben</li>
</ul>
</section>
<!-- Was wird aufbewahrt -->
<section>
<h2 class="text-xl font-bold text-highlighted mb-3">Was wird aufbewahrt?</h2>
<p class="mb-3">Aus rechtlichen Gründen werden folgende Daten weiter gespeichert:</p>
<ul class="list-disc list-inside space-y-1.5">
<li><strong>Rechnungsdaten</strong>: 10 Jahre gemäß § 147 AO und § 257 HGB (Abgabenordnung / Handelsgesetzbuch)</li>
<li><strong>Aggregierte, anonymisierte Statistiken</strong>: Daten ohne jeden Personenbezug, die wir zur Produkt-Verbesserung nutzen (z.B. durchschnittlich gesperrte Mails pro Tag")</li>
</ul>
</section>
<!-- Teil-Löschung -->
<section>
<h2 class="text-xl font-bold text-highlighted mb-3">Nur Teile deiner Daten löschen?</h2>
<p>Du kannst auch einzelne Daten-Kategorien löschen, ohne dein gesamtes Konto zu schließen:</p>
<ul class="list-disc list-inside space-y-1.5 mt-3">
<li>Lyra-Chat-Verlauf: in den App-Einstellungen unter Lyra Verlauf löschen"</li>
<li>Mail-Schutz-Verbindungen: Mail-Tab Postfach aufklappen Trennen"</li>
<li>Demographische Daten: Profil-Bearbeiten einzelne Felder leeren + Speichern</li>
<li>Custom-Domains: Blocker-Tab Domain auswählen Entfernen"</li>
<li>Geräte: Profil Geräteverwaltung Trennen"</li>
</ul>
</section>
<!-- Footer -->
<section class="pt-6 border-t border-default text-xs">
<p class="mb-1"><strong>Verantwortlicher:</strong> Chahine Brini, Lärchenweg 17, 38368 Grasleben</p>
<p class="mb-1"><strong>Datenschutz:</strong> <a href="mailto:datenschutz@rebreak.org" class="text-primary-400 hover:underline">datenschutz@rebreak.org</a></p>
<p class="mb-1"><strong>Datenschutzerklärung:</strong> <a href="/datenschutz" class="text-primary-400 hover:underline">staging.rebreak.org/datenschutz</a></p>
<p class="mt-2"><strong>Stand:</strong> 10. Mai 2026</p>
</section>
</article>
<!-- Mobile Bottom Navigation (gleich wie datenschutz.vue) -->
<nav class="md:hidden fixed bottom-0 left-0 right-0 z-50 flex border-t border-default bg-default/95 backdrop-blur-md pb-safe">
<NuxtLink to="/" class="flex-1 flex flex-col items-center justify-center py-2.5 gap-0.5 text-[11px] text-muted">
<UIcon name="i-heroicons-home" class="size-5" /><span>Start</span>
</NuxtLink>
<NuxtLink to="/pricing" class="flex-1 flex flex-col items-center justify-center py-2.5 gap-0.5 text-[11px] text-muted">
<UIcon name="i-heroicons-credit-card" class="size-5" /><span>Preise</span>
</NuxtLink>
<NuxtLink to="/resources" class="flex-1 flex flex-col items-center justify-center py-2.5 gap-0.5 text-[11px] text-muted">
<UIcon name="i-heroicons-book-open" class="size-5" /><span>Ressourcen</span>
</NuxtLink>
<NuxtLink to="/auth/login" class="flex-1 flex flex-col items-center justify-center py-2.5 gap-0.5 text-[11px] text-muted">
<UIcon name="i-heroicons-user-circle" class="size-5" /><span>Login</span>
</NuxtLink>
</nav>
</div>
</template>
<script setup lang="ts">
useHead({
title: 'Konto löschen ReBreak',
meta: [
{ name: 'description', content: 'Anleitung zum Löschen deines ReBreak-Kontos und aller zugehörigen Daten gemäß Art. 17 DSGVO.' },
{ name: 'robots', content: 'index,follow' },
],
});
definePageMeta({
// public kein auth nötig
});
</script>

View File

@ -0,0 +1,81 @@
<template>
<div class="min-h-screen bg-default flex flex-col items-center justify-center px-6 py-12 text-center"
style="padding-top: max(3rem, env(safe-area-inset-top)); padding-bottom: max(3rem, env(safe-area-inset-bottom))">
<!-- Animated Shield Icon -->
<div class="relative mb-8">
<div
class="w-24 h-24 rounded-full bg-primary-950/60 border border-primary-700/40 flex items-center justify-center mx-auto">
<div class="absolute inset-0 rounded-full bg-primary-500/10 animate-ping" />
<UIcon name="i-heroicons-shield-check" class="text-primary-400 text-5xl relative z-10" />
</div>
</div>
<!-- Main Message -->
<div class="max-w-xs mx-auto mb-8">
<h1 class="text-2xl font-bold text-highlighted mb-3 leading-tight">
{{ $t('blocked.title') }}
</h1>
<!-- Blocked domain badge -->
<div v-if="blockedDomain"
class="inline-flex items-center gap-2 bg-red-950/40 border border-red-700/30 rounded-full px-4 py-1.5 mb-4">
<UIcon name="i-heroicons-x-circle" class="text-red-400 text-sm shrink-0" />
<span class="text-red-300 text-sm font-mono truncate max-w-[200px]">{{ blockedDomain }}</span>
</div>
<p class="text-muted text-sm leading-relaxed">
{{ $t('blocked.message') }}
</p>
</div>
<!-- CTA: Open App -->
<div class="w-full max-w-xs space-y-3">
<a href="https://apps.apple.com/app/rebreak" target="_blank" rel="noopener"
class="w-full flex items-center justify-center gap-3 bg-primary-600 hover:bg-primary-500 active:bg-primary-700 text-white font-semibold rounded-2xl px-6 py-4 transition-colors">
<UIcon name="i-heroicons-chat-bubble-left-ellipsis" class="text-xl" />
{{ $t('blocked.talk_lyra') }}
</a>
<button @click="goBack" class="w-full text-dimmed hover:text-muted text-sm py-2 transition-colors">
{{ $t('blocked.back_to_app') }}
</button>
</div>
<!-- Bottom motivational quote -->
<div class="mt-10 max-w-xs">
<p class="text-xs text-dimmed italic leading-relaxed">
{{ quote }}"
</p>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ layout: false });
const { t } = useI18n();
const route = useRoute();
const blockedDomain = computed(() => {
const d = route.query.domain as string | undefined;
return d ? decodeURIComponent(d) : null;
});
const quotes = computed(() => [
t('blocked.quote_1'),
t('blocked.quote_2'),
t('blocked.quote_3'),
t('blocked.quote_4'),
t('blocked.quote_5'),
]);
const quote = computed(() => quotes.value[Math.floor(Math.random() * quotes.value.length)]);
function goBack() {
if (window.history.length > 1) {
window.history.back();
} else {
navigateTo('/');
}
}
</script>

View File

@ -0,0 +1,397 @@
<template>
<div class="min-h-screen bg-default font-sans pb-16 md:pb-0">
<!-- Header -->
<div class="pt-10 pb-8 text-center px-4">
<div
class="inline-flex items-center gap-2 bg-primary-950/60 border border-primary-800/40 rounded-full px-4 py-1.5 text-sm text-primary-300 mb-6">
<UIcon name="i-heroicons-lock-closed" class="text-primary-400" />
Datenschutz
</div>
<h1 class="text-3xl md:text-5xl font-extrabold text-highlighted mb-3">
Datenschutzerklärung
</h1>
<p class="text-muted text-sm mb-4">
Stand: 9. Mai 2026
</p>
<!-- Sprach-Toggle DE / EN -->
<div class="inline-flex items-center gap-1 bg-elevated border border-default rounded-full p-1">
<span class="px-3 py-1 text-xs font-semibold rounded-full bg-primary-600 text-white">
DE
</span>
<NuxtLink
to="/privacy-policy"
class="px-3 py-1 text-xs font-semibold rounded-full text-muted hover:text-highlighted transition-colors">
EN
</NuxtLink>
</div>
</div>
<!-- Wichtiger Hinweis (Gesundheitsdaten Art. 9 DSGVO) -->
<div class="px-4 max-w-3xl mx-auto mb-8">
<div class="bg-amber-950/40 border border-amber-700/40 rounded-2xl p-5">
<div class="flex items-start gap-3">
<UIcon name="i-heroicons-shield-exclamation"
class="text-amber-400 text-xl shrink-0 mt-0.5" />
<div class="text-sm text-amber-100 leading-relaxed">
<p class="font-semibold mb-2 text-amber-200">
Hinweis zur besonderen Datenkategorie nach Art. 9 DSGVO
</p>
<p>
Die in Rebreak verarbeiteten Daten zu Ihrem Nutzungsverhalten von
Glücksspielangeboten, Ihre Streak- und Trigger-Logs sowie Ihre Konversationen
mit dem KI-Coach Lyra" gelten als <strong>Gesundheitsdaten im Sinne von
Art. 4 Nr. 15 DSGVO</strong>. Sie unterliegen dem besonderen Schutz nach
Art. 9 DSGVO und werden ausschließlich auf Grundlage Ihrer ausdrücklichen
Einwilligung verarbeitet, die Sie jederzeit mit Wirkung für die Zukunft
widerrufen können.
</p>
</div>
</div>
</div>
</div>
<!-- Inhaltsverzeichnis -->
<div class="px-4 max-w-3xl mx-auto mb-10">
<div class="bg-elevated border border-default rounded-2xl p-5">
<h2 class="text-base font-bold text-highlighted mb-3 flex items-center gap-2">
<UIcon name="i-heroicons-list-bullet" class="text-primary-400" />
Inhaltsverzeichnis
</h2>
<ol class="text-sm text-muted space-y-1.5 list-decimal list-inside">
<li><a href="#par1" class="hover:text-primary-400 transition-colors">Verantwortlicher und Kontakt</a></li>
<li><a href="#par2" class="hover:text-primary-400 transition-colors">Datenschutzbeauftragter</a></li>
<li><a href="#par3" class="hover:text-primary-400 transition-colors">Begriffsbestimmungen</a></li>
<li><a href="#par4" class="hover:text-primary-400 transition-colors">Verarbeitete Datenkategorien</a></li>
<li><a href="#par5" class="hover:text-primary-400 transition-colors">Zwecke und Rechtsgrundlagen der Verarbeitung</a></li>
<li><a href="#par6" class="hover:text-primary-400 transition-colors">Empfänger und Auftragsverarbeiter</a></li>
<li><a href="#par7" class="hover:text-primary-400 transition-colors">Datenübermittlung in Drittländer</a></li>
<li><a href="#par8" class="hover:text-primary-400 transition-colors">Speicherdauer und Löschung</a></li>
<li><a href="#par9" class="hover:text-primary-400 transition-colors">Cookies, Local Storage und Tracking</a></li>
<li><a href="#par10" class="hover:text-primary-400 transition-colors">Push-Benachrichtigungen</a></li>
<li><a href="#par11" class="hover:text-primary-400 transition-colors">Technische und organisatorische Sicherheitsmaßnahmen</a></li>
<li><a href="#par12" class="hover:text-primary-400 transition-colors">Mail-Schutz / Mail-Agent</a></li>
<li><a href="#par13" class="hover:text-primary-400 transition-colors">Ihre Betroffenenrechte</a></li>
<li><a href="#par14" class="hover:text-primary-400 transition-colors">Beschwerderecht bei der Aufsichtsbehörde</a></li>
<li><a href="#par15" class="hover:text-primary-400 transition-colors">Automatisierte Entscheidungsfindung und KI-Coach Lyra"</a></li>
<li><a href="#par16" class="hover:text-primary-400 transition-colors">Änderungen dieser Datenschutzerklärung</a></li>
</ol>
</div>
</div>
<!-- Inhalt -->
<article class="px-4 max-w-3xl mx-auto pb-24 text-sm text-muted leading-relaxed space-y-10">
<!-- § 1 Verantwortlicher -->
<section id="par1">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 1 Verantwortlicher und Kontakt</h2>
<p class="mb-3">
(1) Verantwortlicher im Sinne des Art. 4 Nr. 7 DSGVO sowie sonstiger
datenschutzrechtlicher Bestimmungen für die Verarbeitung personenbezogener Daten
im Zusammenhang mit der Nutzung der mobilen Anwendung sowie der zugehörigen
Web-Anwendung Rebreak" (nachfolgend gemeinsam „App" oder Rebreak") ist:
</p>
<div class="bg-elevated border border-default rounded-xl p-4 mb-3 not-prose">
<p class="text-highlighted font-semibold mb-1">Chahine Brini</p>
<p>(natürliche Person, einzelkaufmännisch handelnd)</p>
<p class="mt-2">Lärchenweg 17</p>
<p>38368 Grasleben</p>
<p>Deutschland</p>
<p class="mt-2">Telefon: <a href="tel:+4915226897875" class="text-primary-400 hover:underline">+49 152 26897875</a></p>
<p class="mt-1">E-Mail (Datenschutz): <a href="mailto:datenschutz@rebreak.org" class="text-primary-400 hover:underline">datenschutz@rebreak.org</a></p>
</div>
<p class="mb-3">
(2) <strong>Hinweis zur geplanten Vertragsübernahme:</strong> Eine Überführung
des Geschäftsbetriebs in eine in Gründung befindliche Raynis GmbH" mit Sitz in
Deutschland ist geplant. Mit Wirksamwerden der Übernahme wird die Raynis GmbH
neue verantwortliche Stelle im Sinne des Art. 4 Nr. 7 DSGVO. Sämtliche
bestehenden Einwilligungen, Verträge zur Auftragsverarbeitung und
Verarbeitungsverzeichnisse werden im Wege der Universalsukzession bzw. durch
gesonderte Übertragung auf die GmbH überführt.
</p>
<p>
(3) Rebreak strebt die Listung als <strong>Digitale Gesundheitsanwendung (DiGA)</strong>
nach § 33a SGB V beim BfArM an. Diese Anbahnungen führen <strong>nicht</strong> zu
einer Übermittlung personenbezogener Daten der Nutzer.
</p>
</section>
<!-- § 2 DSB -->
<section id="par2">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 2 Datenschutzbeauftragter</h2>
<p class="mb-3">
Eine gesetzliche Pflicht zur Bestellung eines Datenschutzbeauftragten besteht derzeit
<strong>nicht</strong>. Bis zur formalen Bestellung richten Sie alle Anfragen an:
</p>
<div class="bg-elevated border border-default rounded-xl p-4 not-prose">
<p>E-Mail: <a href="mailto:datenschutz@rebreak.org" class="text-primary-400 hover:underline">datenschutz@rebreak.org</a></p>
<p class="mt-1 text-xs text-muted">Betreff: Datenschutz Rebreak"</p>
</div>
</section>
<!-- § 3 Begriffsbestimmungen -->
<section id="par3">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 3 Begriffsbestimmungen</h2>
<ul class="list-disc list-inside space-y-1.5">
<li><strong>Personenbezogene Daten</strong> (Art. 4 Nr. 1 DSGVO): alle Informationen, die sich auf eine identifizierte oder identifizierbare natürliche Person beziehen.</li>
<li><strong>Gesundheitsdaten</strong> (Art. 4 Nr. 15 DSGVO): personenbezogene Daten, die sich auf die körperliche oder geistige Gesundheit beziehen, einschließlich suchtbezogener Verhaltensweisen.</li>
<li><strong>Verarbeitung</strong> (Art. 4 Nr. 2 DSGVO): jeder Vorgang im Zusammenhang mit personenbezogenen Daten.</li>
<li><strong>Auftragsverarbeiter</strong> (Art. 4 Nr. 8 DSGVO): Dritte, die Daten im Auftrag des Verantwortlichen verarbeiten.</li>
<li><strong>Pseudonymisierung</strong> (Art. 4 Nr. 5 DSGVO): Verarbeitung, bei der Daten ohne Zusatzinformationen nicht mehr zugeordnet werden können.</li>
<li><strong>Drittland</strong>: ein Staat außerhalb der EU/EWR.</li>
</ul>
</section>
<!-- § 4 Datenkategorien -->
<section id="par4">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 4 Verarbeitete Datenkategorien</h2>
<ul class="list-disc list-inside space-y-2">
<li><strong>Stammdaten / Account:</strong> E-Mail-Adresse, Pseudonym (Nickname), optional Avatar, Sprache, Zeitzone.</li>
<li><strong>Authentifizierungsdaten:</strong> Passwort-Hash, OAuth-Identifier, Sitzungs-Token, Geräte-Identifier.</li>
<li><strong>Demographische Daten (optional):</strong> Geburtsjahr, Geschlecht, Beruf, Ausbildungsgrad, Bundesland. Ausschließlich user-initiated, keine automatische Extraktion.</li>
<li><strong>Gesundheits- und Verhaltensdaten (Art. 9 DSGVO):</strong> Glücksspielverhalten, Streak-Zähler, Trigger-Logs, Urge-Einträge, SOS-Aktivierungen, Chat mit KI-Coach Lyra", Lyra-Memories.</li>
<li><strong>Inhalts- und Community-Daten:</strong> Posts, Kommentare, Reaktionen (unter Pseudonym).</li>
<li><strong>Filter- und Sperrdaten:</strong> Custom-Domains, Filterstatistik, Cooldown-Konfiguration.</li>
<li><strong>Mail-Schutz-Daten (opt-in):</strong> OAuth-Tokens, Header-Hashes, Klassifikations-Ergebnis. E-Mail-Inhalte werden nicht dauerhaft gespeichert.</li>
<li><strong>Zahlungs- und Abonnement-Daten:</strong> Stripe-Customer-ID, Tarif-Status, Trial-Zeitraum.</li>
<li><strong>Technische Logdaten:</strong> IP (gekürzt), Datum/Uhrzeit, Geräteinformationen.</li>
</ul>
</section>
<!-- § 5 Zwecke + Rechtsgrundlagen -->
<section id="par5">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 5 Zwecke und Rechtsgrundlagen der Verarbeitung</h2>
<div class="overflow-x-auto -mx-4 px-4 mb-3">
<table class="w-full text-xs border border-default rounded-lg overflow-hidden">
<thead class="bg-elevated text-highlighted text-left">
<tr>
<th class="p-2.5 border-b border-default font-semibold">Zweck</th>
<th class="p-2.5 border-b border-default font-semibold">Rechtsgrundlage</th>
</tr>
</thead>
<tbody class="text-muted">
<tr class="border-b border-default/60"><td class="p-2.5">Bereitstellung des Nutzerkontos</td><td class="p-2.5">Art. 6 Abs. 1 lit. b DSGVO</td></tr>
<tr class="border-b border-default/60"><td class="p-2.5">Selbsthilfe-Funktionen (Streaks, SOS, KI-Coach)</td><td class="p-2.5">Art. 9 Abs. 2 lit. a DSGVO</td></tr>
<tr class="border-b border-default/60"><td class="p-2.5">Demographische Profilangaben (DiGA-Evidenz)</td><td class="p-2.5">Art. 6 Abs. 1 lit. a / Art. 9 Abs. 2 lit. a DSGVO</td></tr>
<tr class="border-b border-default/60"><td class="p-2.5">Pro-Trial-Reward für vollständige Profilangaben</td><td class="p-2.5">Art. 6 Abs. 1 lit. b DSGVO</td></tr>
<tr class="border-b border-default/60"><td class="p-2.5">Domain- und URL-Filterung</td><td class="p-2.5">Art. 6 Abs. 1 lit. b / Art. 9 Abs. 2 lit. a DSGVO</td></tr>
<tr class="border-b border-default/60"><td class="p-2.5">Mail-Schutz (opt-in)</td><td class="p-2.5">Art. 6 Abs. 1 lit. a / Art. 9 Abs. 2 lit. a DSGVO</td></tr>
<tr class="border-b border-default/60"><td class="p-2.5">Community-Bereich</td><td class="p-2.5">Art. 6 Abs. 1 lit. a DSGVO</td></tr>
<tr class="border-b border-default/60"><td class="p-2.5">Zahlungsabwicklung</td><td class="p-2.5">Art. 6 Abs. 1 lit. b DSGVO</td></tr>
<tr class="border-b border-default/60"><td class="p-2.5">IT-Sicherheit der App</td><td class="p-2.5">Art. 6 Abs. 1 lit. f DSGVO</td></tr>
<tr><td class="p-2.5">Gesetzliche Aufbewahrungspflichten</td><td class="p-2.5">Art. 6 Abs. 1 lit. c DSGVO</td></tr>
</tbody>
</table>
</div>
<p class="mb-3">
(3) <strong>Trennung strukturierter Profilangaben und narrativer Inhalte:</strong>
Demographische Daten werden ausschließlich aus der Profil-Eingabemaske gewonnen.
Eine automatische Extraktion durch den KI-Coach Lyra" findet ausdrücklich nicht statt.
</p>
<p class="mb-3">
(4) <strong>Pro-Trial-Reward:</strong> Free-Nutzer, die ihr demographisches Profil vollständig ausfüllen,
erhalten als Anerkennung eine einwöchige Pro-Aktivierung. Die Verarbeitung Ihrer Daten ist
nicht an den Erhalt des Trials gekoppelt; Ablehnung hat keine Auswirkung auf den Free-Tarif.
</p>
</section>
<!-- § 6 Empfänger -->
<section id="par6">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 6 Empfänger und Auftragsverarbeiter</h2>
<div class="overflow-x-auto -mx-4 px-4 mb-3">
<table class="w-full text-xs border border-default rounded-lg overflow-hidden">
<thead class="bg-elevated text-highlighted text-left">
<tr>
<th class="p-2.5 border-b border-default font-semibold">Anbieter</th>
<th class="p-2.5 border-b border-default font-semibold">Zweck</th>
<th class="p-2.5 border-b border-default font-semibold">Sitz</th>
</tr>
</thead>
<tbody class="text-muted">
<tr class="border-b border-default/60"><td class="p-2.5">Hetzner Online GmbH</td><td class="p-2.5">Hosting, Datenbank, Backups</td><td class="p-2.5">Deutschland (EU)</td></tr>
<tr class="border-b border-default/60"><td class="p-2.5">Stripe Payments Europe</td><td class="p-2.5">Zahlungsabwicklung</td><td class="p-2.5">Irland (EU); USA DPF + SCCs</td></tr>
<tr class="border-b border-default/60"><td class="p-2.5">Groq, Inc.</td><td class="p-2.5">LLM-Inferenz (Lyra, Free/Pro)</td><td class="p-2.5">USA SCCs + TIA</td></tr>
<tr class="border-b border-default/60"><td class="p-2.5">Anthropic PBC</td><td class="p-2.5">LLM-Inferenz (Lyra, Legend)</td><td class="p-2.5">USA SCCs + TIA</td></tr>
<tr class="border-b border-default/60"><td class="p-2.5">OpenRouter, Inc.</td><td class="p-2.5">LLM-Routing (Fallback)</td><td class="p-2.5">USA SCCs + TIA</td></tr>
<tr class="border-b border-default/60"><td class="p-2.5">Cartesia, Inc.</td><td class="p-2.5">Text-to-Speech</td><td class="p-2.5">USA SCCs + TIA</td></tr>
<tr class="border-b border-default/60"><td class="p-2.5">ElevenLabs Inc.</td><td class="p-2.5">Text-to-Speech (alternativ)</td><td class="p-2.5">USA SCCs + TIA</td></tr>
<tr class="border-b border-default/60"><td class="p-2.5">Deepgram, Inc.</td><td class="p-2.5">Speech-to-Text</td><td class="p-2.5">USA SCCs + TIA</td></tr>
<tr class="border-b border-default/60"><td class="p-2.5">Cloudflare, Inc.</td><td class="p-2.5">CDN, DNS, DDoS-Schutz</td><td class="p-2.5">USA SCCs + DPF</td></tr>
<tr class="border-b border-default/60"><td class="p-2.5">Apple Inc. (APNs)</td><td class="p-2.5">Push iOS</td><td class="p-2.5">USA DPF + SCCs</td></tr>
<tr class="border-b border-default/60"><td class="p-2.5">Google LLC (FCM)</td><td class="p-2.5">Push Android</td><td class="p-2.5">USA DPF + SCCs</td></tr>
<tr><td class="p-2.5">Infisical Inc.</td><td class="p-2.5">Secret-Management (keine Nutzerdaten)</td><td class="p-2.5">USA SCCs</td></tr>
</tbody>
</table>
</div>
</section>
<!-- § 7 Drittland -->
<section id="par7">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 7 Datenübermittlung in Drittländer</h2>
<p class="mb-3">
Soweit personenbezogene Daten an Empfänger in Drittländern (insbesondere USA) übermittelt werden,
erfolgt dies unter Beachtung der Art. 44 ff. DSGVO. Als Garantien setzen wir
<strong>Standardvertragsklauseln (SCCs)</strong> der EU-Kommission ein;
soweit Anbieter unter dem <strong>EU-US Data Privacy Framework</strong> zertifiziert sind,
stützt sich die Übermittlung zusätzlich auf Art. 45 DSGVO.
</p>
<p class="mb-3">
Ein <strong>Transfer-Impact-Assessment (TIA)</strong> wurde durchgeführt.
Ergänzende Schutzmaßnahmen: Übermittlung ohne Klarnamen/E-Mail, TLS 1.3 mit Forward Secrecy,
Datenminimierung, no-training-Zusagen der LLM-Anbieter.
</p>
</section>
<!-- § 8 Speicherdauer -->
<section id="par8">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 8 Speicherdauer und Löschung</h2>
<ul class="list-disc list-inside space-y-2">
<li><strong>Account- und Stammdaten:</strong> bis zur Konto-Löschung.</li>
<li><strong>Demographische Daten:</strong> bis zur Löschung durch den Nutzer, spätestens bei Konto-Löschung.</li>
<li><strong>Chat-Verläufe und Lyra-Memories:</strong> standardmäßig 12 Monate, konfigurierbar.</li>
<li><strong>Trigger-, Urge- und Streak-Logs:</strong> bis zu 24 Monate, dann Aggregation.</li>
<li><strong>Community-Inhalte:</strong> bis zur Löschung oder Konto-Löschung.</li>
<li><strong>Mail-Schutz-Daten:</strong> OAuth-Tokens bis Verbindung getrennt; Header-Hashes 90 Tage.</li>
<li><strong>Server-Logs:</strong> 14 Tage.</li>
<li><strong>Rechnungsunterlagen:</strong> 10 Jahre (§§ 147 AO, 257 HGB).</li>
</ul>
</section>
<!-- § 9 Cookies -->
<section id="par9">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 9 Cookies, Local Storage und Tracking</h2>
<p class="mb-3">
Rebreak setzt <strong>keine Tracking-Pixel und keine Drittanbieter-Analytics-Cookies</strong> ein.
Wir nutzen ausschließlich technisch erforderliche Speichermechanismen (§ 25 Abs. 2 Nr. 2 TTDSG):
Authentifizierungs-Cookies, UI-Einstellungen, Filter-Cache.
</p>
</section>
<!-- § 10 Push -->
<section id="par10">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 10 Push-Benachrichtigungen</h2>
<p class="mb-3">
Mit Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO) versenden wir Push-Benachrichtigungen
via Apple APNs (iOS) und Google FCM (Android). Sie können den Empfang jederzeit in den
Systemeinstellungen deaktivieren.
</p>
</section>
<!-- § 11 Sicherheit -->
<section id="par11">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 11 Technische und organisatorische Sicherheitsmaßnahmen</h2>
<ul class="list-disc list-inside space-y-1.5 mb-3">
<li>TLS 1.3 für alle Verbindungen;</li>
<li>Festplattenverschlüsselung (Encryption at Rest);</li>
<li>JWT mit kurzlebigen Access-Tokens und httpOnly-Refresh-Cookies;</li>
<li>OAuth 2.0 mit PKCE;</li>
<li>RBAC und Row-Level-Security in PostgreSQL;</li>
<li>Regelmäßige Backups, verschlüsselt;</li>
<li>Secret-Management via Infisical (kein Klartext-Speichern);</li>
<li>Datenminimierung gemäß Art. 5 Abs. 1 lit. c DSGVO.</li>
</ul>
<p class="mb-3">
<strong>Pseudonymisierung gegenüber LLM-Anbietern:</strong> Chat-Inhalte werden ohne
Klarnamen, E-Mail-Adressen oder Account-IDs übermittelt. Übertragen wird nur der Nickname
und Gesprächsinhalt. Ab Q3 2026 ist eine NER-Pipeline zur automatischen Maskierung geplant.
</p>
<p>
<strong>Anonymität durch Pseudonym:</strong> Innerhalb der App sind Sie für andere
ausschließlich unter Ihrem Nickname sichtbar. Klarnamen werden niemals angezeigt.
</p>
</section>
<!-- § 12 Mail-Schutz -->
<section id="par12">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 12 Mail-Schutz / Mail-Agent</h2>
<p class="mb-3">
Der Mail-Schutz ist ein optionales Opt-in-Modul, das eingehende E-Mails auf
Glücksspiel-bezogene Inhalte klassifiziert. Rechtsgrundlage ist Art. 6 Abs. 1 lit. a und
Art. 9 Abs. 2 lit. a DSGVO. E-Mail-Inhalte werden nicht dauerhaft gespeichert.
Sie können den Mail-Schutz jederzeit deaktivieren; die OAuth-Tokens werden dann
unverzüglich gelöscht.
</p>
</section>
<!-- § 13 Betroffenenrechte -->
<section id="par13">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 13 Ihre Betroffenenrechte</h2>
<ul class="list-disc list-inside space-y-1.5">
<li><strong>Auskunft</strong> (Art. 15 DSGVO)</li>
<li><strong>Berichtigung</strong> (Art. 16 DSGVO)</li>
<li><strong>Löschung</strong> (Art. 17 DSGVO)</li>
<li><strong>Einschränkung</strong> (Art. 18 DSGVO)</li>
<li><strong>Datenübertragbarkeit</strong> (Art. 20 DSGVO)</li>
<li><strong>Widerspruch</strong> (Art. 21 DSGVO)</li>
<li><strong>Widerruf</strong> einer Einwilligung (Art. 7 Abs. 3 DSGVO)</li>
</ul>
<p class="mt-3">
Anfragen an:
<a href="mailto:datenschutz@rebreak.org" class="text-primary-400 hover:underline">datenschutz@rebreak.org</a>.
In der App: Konto-Löschung und Datenexport über die Account-Einstellungen.
</p>
</section>
<!-- § 14 Aufsichtsbehörde -->
<section id="par14">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 14 Beschwerderecht bei der Aufsichtsbehörde</h2>
<p class="mb-3">Sie haben das Recht, sich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO).</p>
<div class="bg-elevated border border-default rounded-xl p-4 not-prose">
<p class="text-highlighted font-semibold mb-1">Die Landesbeauftragte für den Datenschutz Niedersachsen</p>
<p>Prinzenstraße 5, 30159 Hannover</p>
<p class="mt-1">Tel: +49 511 120-4500</p>
<p>E-Mail: <a href="mailto:poststelle@lfd.niedersachsen.de" class="text-primary-400 hover:underline">poststelle@lfd.niedersachsen.de</a></p>
</div>
</section>
<!-- § 15 KI-Coach -->
<section id="par15">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 15 Automatisierte Entscheidungsfindung und KI-Coach Lyra"</h2>
<p class="mb-3">
Eine rechtlich erhebliche automatisierte Entscheidungsfindung (Art. 22 DSGVO)
findet <strong>nicht</strong> statt. Der KI-Coach Lyra" nutzt je nach Tarif:
</p>
<ul class="list-disc list-inside space-y-1.5 mb-3">
<li><strong>Free und Pro:</strong> Llama-Modelle via Groq (USA)</li>
<li><strong>Legend:</strong> Claude Haiku 4.5 via Anthropic (USA)</li>
<li><strong>Fallback:</strong> ergänzende Modelle via OpenRouter (USA)</li>
</ul>
<p>
Die Inhalte sind statistisch generiert und stellen <strong>keine medizinische, therapeutische
oder rechtliche Beratung</strong> dar. In akuten Krisen: Telefonseelsorge 0800/111 0 111
oder ärztlicher Notdienst 116 117.
</p>
</section>
<!-- § 16 Änderungen -->
<section id="par16">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 16 Änderungen dieser Datenschutzerklärung</h2>
<p>
Wesentliche Änderungen werden per E-Mail oder In-App-Mitteilung angekündigt.
Die aktuelle Fassung ist stets unter dieser URL abrufbar.
</p>
</section>
<section class="pt-6 border-t border-default">
<p class="text-xs text-muted mb-2">
<strong class="text-highlighted">Verantwortlicher:</strong> Chahine Brini, Lärchenweg 17, 38368 Grasleben ·
<a href="mailto:datenschutz@rebreak.org" class="text-primary-400 hover:underline">datenschutz@rebreak.org</a>
</p>
<p class="text-xs text-muted"><strong class="text-highlighted">Stand:</strong> 9. Mai 2026</p>
</section>
</article>
</div>
</template>
<script setup lang="ts">
definePageMeta({ layout: "default" });
useHead({
title: 'Datenschutzerklärung Rebreak',
meta: [
{ name: 'description', content: 'Datenschutzerklärung der Rebreak-App nach Art. 13/14 DSGVO. Stand: 9. Mai 2026.' },
{ name: 'robots', content: 'index,follow' },
],
});
</script>

View File

@ -0,0 +1,104 @@
<template>
<div class="min-h-screen bg-gray-950 text-white flex flex-col items-center justify-center px-4 py-16">
<div class="w-full max-w-md">
<!-- Logo / Brand -->
<div class="flex items-center gap-3 mb-10">
<div class="w-12 h-12 rounded-2xl bg-indigo-500 flex items-center justify-center">
<span class="text-2xl font-bold">R</span>
</div>
<div>
<h1 class="text-2xl font-bold tracking-tight">Rebreak</h1>
<p class="text-sm text-gray-400">Gambling Recovery</p>
</div>
</div>
<!-- Header -->
<h2 class="text-3xl font-extrabold mb-2">Rebreak für Android</h2>
<p class="text-gray-400 mb-1 text-sm">
Version {{ version }} &nbsp;·&nbsp; Build {{ buildDate }}
</p>
<span
class="inline-block bg-amber-500/15 text-amber-400 text-xs font-semibold px-2.5 py-1 rounded-full mb-8"
>
Beta iOS ist die Hauptplattform
</span>
<!-- Download Button -->
<a
:href="apkUrl"
class="block w-full bg-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 transition-colors text-white text-center font-bold text-lg py-4 rounded-2xl mb-4 shadow-lg shadow-indigo-900/40"
download
>
APK herunterladen ({{ apkSizeMb }} MB)
</a>
<!-- SHA256 -->
<p class="text-xs text-gray-500 text-center break-all mb-10">
SHA256: <span class="font-mono">{{ sha256 }}</span>
</p>
<!-- Install Instructions -->
<div class="bg-gray-900 rounded-2xl p-6 mb-8">
<h3 class="font-bold text-base mb-4 text-white">Installation in 3 Schritten</h3>
<ol class="space-y-4">
<li class="flex gap-3">
<span class="w-7 h-7 shrink-0 rounded-full bg-indigo-600 flex items-center justify-center text-sm font-bold">1</span>
<div>
<p class="font-medium text-sm text-white">APK laden</p>
<p class="text-xs text-gray-400 mt-0.5">Oben auf "APK herunterladen" tippen und die Datei speichern.</p>
</div>
</li>
<li class="flex gap-3">
<span class="w-7 h-7 shrink-0 rounded-full bg-indigo-600 flex items-center justify-center text-sm font-bold">2</span>
<div>
<p class="font-medium text-sm text-white">Unbekannte Quellen erlauben</p>
<p class="text-xs text-gray-400 mt-0.5">
Einstellungen Apps Spezieller App-Zugriff Unbekannte Apps installieren deinen Browser auswählen erlauben.
</p>
</div>
</li>
<li class="flex gap-3">
<span class="w-7 h-7 shrink-0 rounded-full bg-indigo-600 flex items-center justify-center text-sm font-bold">3</span>
<div>
<p class="font-medium text-sm text-white">APK öffnen &amp; installieren</p>
<p class="text-xs text-gray-400 mt-0.5">Heruntergeladene Datei im Dateimanager öffnen und "Installieren" bestätigen.</p>
</div>
</li>
</ol>
</div>
<!-- Beta Notice -->
<div class="bg-amber-950/40 border border-amber-800/30 rounded-xl p-4 mb-8">
<p class="text-amber-300 text-xs leading-relaxed">
<strong>Beta-Hinweis:</strong> Diese APK ist eine Vorschau-Version. Nicht alle Features
sind fertig. Fehler bitte per E-Mail melden:
<a href="mailto:support@rebreak.org" class="underline">support@rebreak.org</a>
</p>
</div>
<!-- Footer -->
<p class="text-center text-xs text-gray-600">
&copy; {{ new Date().getFullYear() }} Rebreak &nbsp;·&nbsp;
<NuxtLink to="/datenschutz" class="hover:text-gray-400">Datenschutz</NuxtLink>
</p>
</div>
</div>
</template>
<script setup lang="ts">
// Diese Werte werden bei jedem Release-Build manuell oder per Script aktualisiert.
const version = "0.1.0";
const buildDate = "2026-04-28";
const apkSizeMb = "—"; // Wird nach Build eingetragen
const sha256 = "— wird nach Build eingetragen —";
const apkUrl = "/downloads/rebreak-android-latest.apk";
useSeoMeta({
title: "Rebreak für Android APK Download",
description: "Lade die Rebreak Gambling-Recovery App als APK für Android herunter. Beta-Version iOS ist die Hauptplattform.",
});
definePageMeta({
layout: false,
});
</script>

View File

@ -0,0 +1,131 @@
<template>
<div class="min-h-screen bg-default font-sans pb-16 md:pb-0">
<!-- Header -->
<div class="pt-10 pb-8 text-center px-4">
<div
class="inline-flex items-center gap-2 bg-primary-950/60 border border-primary-800/40 rounded-full px-4 py-1.5 text-sm text-primary-300 mb-6">
<UIcon name="i-heroicons-identification" class="text-primary-400" />
Rechtliches
</div>
<h1 class="text-3xl md:text-5xl font-extrabold text-highlighted mb-3">
Impressum
</h1>
<p class="text-muted text-sm">
Stand: 1. Mai 2026
</p>
</div>
<!-- Inhalt -->
<article class="px-4 max-w-3xl mx-auto pb-24 text-sm text-muted leading-relaxed space-y-10">
<section id="par1">
<h2 class="text-xl font-bold text-highlighted mb-3">Angaben gemäß § 5 DDG</h2>
<div class="bg-elevated border border-default rounded-xl p-5 not-prose">
<p class="text-highlighted font-semibold text-base mb-1">Chahine Brini</p>
<p class="mb-3 text-muted">(natürliche Person, einzelkaufmännisch handelnd)</p>
<p class="mb-1"><strong class="text-highlighted">Postanschrift:</strong></p>
<p class="mb-3 text-muted">
Lärchenweg 17<br />
38368 Grasleben<br />
Deutschland
</p>
<p class="mb-1"><strong class="text-highlighted">Kontakt:</strong></p>
<p>
E-Mail: <a href="mailto:c.brini@icloud.com" class="text-primary-400 hover:underline">c.brini@icloud.com</a>
</p>
</div>
<p class="mt-3 text-xs text-muted italic">
Hinweis: Der Geschäftsbetrieb wird derzeit als Einzelkaufmann geführt.
Eine Überführung in die in Gründung befindliche Raynis GmbH" ist geplant;
die Angaben werden entsprechend aktualisiert.
</p>
</section>
<section id="par2">
<h2 class="text-xl font-bold text-highlighted mb-3">Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV</h2>
<div class="bg-elevated border border-default rounded-xl p-4 not-prose">
<p class="text-highlighted font-semibold mb-1">Chahine Brini</p>
<p>Anschrift wie oben</p>
</div>
</section>
<section id="par3">
<h2 class="text-xl font-bold text-highlighted mb-3">Umsatzsteuer und unternehmensbezogene Angaben</h2>
<p class="mb-3">
Eine Umsatzsteuer-Identifikationsnummer gemäß § 27a UStG wird nach Erteilung ergänzt.
Eine Eintragung im Handelsregister besteht für den derzeitigen Anbieter nicht.
</p>
</section>
<section id="par4">
<h2 class="text-xl font-bold text-highlighted mb-3">Online-Streitbeilegung gemäß Art. 14 Abs. 1 ODR-VO</h2>
<p class="mb-3">
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung bereit:
<a href="https://ec.europa.eu/consumers/odr" class="text-primary-400 hover:underline" target="_blank" rel="noopener">
https://ec.europa.eu/consumers/odr
</a>
</p>
<p>
E-Mail für Verbraucheranfragen:
<a href="mailto:c.brini@icloud.com" class="text-primary-400 hover:underline">c.brini@icloud.com</a>
</p>
</section>
<section id="par5">
<h2 class="text-xl font-bold text-highlighted mb-3">Verbraucherstreitbeilegung</h2>
<p>
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer
Verbraucherschlichtungsstelle gemäß § 36 VSBG teilzunehmen.
</p>
</section>
<section id="par6">
<h2 class="text-xl font-bold text-highlighted mb-3">Haftung für Inhalte</h2>
<p class="mb-3">
Als Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für eigene Inhalte verantwortlich.
Bei Bekanntwerden von Rechtsverletzungen werden wir diese Inhalte umgehend entfernen.
</p>
</section>
<section id="par7">
<h2 class="text-xl font-bold text-highlighted mb-3">Haftung für Links</h2>
<p>
Unser Angebot enthält Links zu externen Seiten, auf deren Inhalte wir keinen Einfluss haben.
Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Links umgehend entfernen.
</p>
</section>
<section id="par8">
<h2 class="text-xl font-bold text-highlighted mb-3">Urheberrecht</h2>
<p>
Die durch den Anbieter erstellten Inhalte unterliegen dem deutschen Urheberrecht.
Downloads und Kopien sind nur für den privaten, nicht kommerziellen Gebrauch gestattet.
</p>
</section>
<section class="pt-6 border-t border-default">
<p class="text-xs text-muted mb-2">
<strong class="text-highlighted">Hosting:</strong> Hetzner Online GmbH, Falkenstein/Nürnberg, Deutschland.
</p>
<p class="text-xs text-muted mb-2">
<strong class="text-highlighted">Datenschutz:</strong>
<NuxtLink to="/datenschutz" class="text-primary-400 hover:underline">Datenschutzerklärung</NuxtLink>
</p>
<p class="text-xs text-muted"><strong class="text-highlighted">Stand:</strong> 1. Mai 2026</p>
</section>
</article>
</div>
</template>
<script setup lang="ts">
definePageMeta({ layout: "default" });
useHead({
title: 'Impressum Rebreak',
meta: [
{ name: 'description', content: 'Impressum der Rebreak-App nach § 5 DDG.' },
{ name: 'robots', content: 'index,follow' },
],
});
</script>

View File

@ -0,0 +1,449 @@
<template>
<div class="bg-default overflow-x-hidden">
<!-- HERO -->
<section class="relative min-h-[80dvh] flex flex-col items-center justify-center px-4 text-center">
<div class="absolute inset-0 bg-linear-to-b from-gray-950 via-primary-950/20 to-gray-950 pointer-events-none" />
<div
class="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-175 h-175 bg-primary-900/15 rounded-full blur-3xl pointer-events-none" />
<div class="relative max-w-3xl mx-auto">
<div
class="inline-flex items-center gap-2 bg-primary-950/60 border border-primary-800/40 rounded-full px-4 py-1.5 text-sm text-primary-300 mb-8">
<UIcon name="i-heroicons-user-group" class="text-primary-400" />
{{ $t('landing.hero_badge') }}
</div>
<h1 class="text-3xl md:text-7xl font-black text-highlighted leading-[1.05] tracking-tight mb-6">
{{ $t('landing.hero_title') }}
<span class="block text-2xl text-transparent bg-clip-text bg-linear-to-r from-primary-300 to-primary-500">
{{ $t('landing.hero_subtitle') }}
</span>
</h1>
<p class="text-sm text-muted max-w-lg mx-auto mb-10 leading-relaxed">
{{ $t('landing.hero_text') }}
</p>
<img src="/encrypted.svg" alt="Community Illustration" class="w-15 max-w-md mx-auto mb-10" />
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
<a href="https://apps.apple.com/app/rebreak" target="_blank" rel="noopener">
<UButton size="xl" class="px-8">
<UIcon name="i-heroicons-bolt" />
{{ $t('landing.cta_start') }}
</UButton>
</a>
</div>
<div class="flex justify-center gap-x-10 mt-16 text-center">
<div>
<div class="text-3xl font-extrabold text-highlighted">
<AnimatedCounter :target="300" :duration="2000" />k+
</div>
<div class="text-xs text-muted mt-1">{{ $t('landing.stat_affected') }}</div>
</div>
<div>
<div class="text-3xl font-extrabold text-primary-400">
<AnimatedCounter :target="208" :duration="1600" :delay="200" />k+
</div>
<div class="text-xs text-muted mt-1">{{ $t('landing.stat_blocked') }}</div>
</div>
<div>
<div class="text-3xl font-extrabold text-primary-400">0</div>
<div class="text-xs text-muted mt-1">{{ $t('landing.stat_free') }}</div>
</div>
</div>
<div class="mt-8">
<UButton size="lg" variant="ghost" color="neutral" @click="scrollToInfo">
{{ $t('landing.more_info') }}
<UIcon name="i-heroicons-chevron-down" />
</UButton>
</div>
</div>
</section>
<!-- INFO SECTIONS -->
<div v-if="showInfoSections" ref="infoSections">
<!-- Blocker -->
<section class="py-8 px-4">
<div v-motion :initial="{ opacity: 0, y: 50 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
class="max-w-6xl mx-auto grid lg:grid-cols-2 gap-16 items-center">
<div class="order-2 lg:order-1">
<div
class="inline-flex items-center gap-2 bg-primary-950/60 border border-primary-800/40 rounded-full px-3 py-1 text-xs text-primary-300 mb-6">
<UIcon name="i-heroicons-shield-exclamation" />
{{ $t('landing.blocker_badge') }}
</div>
<h2 class="text-4xl md:text-5xl font-black text-highlighted leading-tight mb-6">
<AnimatedCounter :target="100" :duration="2500" />k+ {{ $t('landing.blocker_title_domains') }}<br />
<span class="text-primary-400">{{ $t('landing.blocker_title_activated') }}</span>
</h2>
<p class="text-lg text-muted leading-relaxed mb-8">
{{ $t('landing.blocker_desc') }}
</p>
<ul class="space-y-3 text-sm text-default">
<li v-for="f in blockerFeatures" :key="f" class="flex items-center gap-3">
<UIcon name="i-heroicons-check-circle" class="text-primary-400 shrink-0" />{{ f }}
</li>
</ul>
</div>
<div class="order-1 lg:order-2 flex items-center justify-center">
<div
class="w-72 h-72 rounded-3xl bg-linear-to-br from-primary-950/60 to-primary-900/20 border border-primary-800/20 flex items-center justify-center shadow-2xl shadow-primary-950/50">
<UIcon name="i-heroicons-shield-check" class="text-primary-400 w-32 h-32" />
</div>
</div>
</div>
</section>
<!-- OASIS Warning -->
<section class="py-8 px-4">
<div v-motion :initial="{ opacity: 0, y: 40 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
class="max-w-4xl mx-auto">
<div class="bg-orange-950/20 border border-orange-800/20 rounded-3xl p-6 md:p-8">
<div
class="inline-flex items-center gap-2 bg-orange-950/60 border border-orange-800/40 rounded-full px-3 py-1 text-xs text-orange-300 mb-5">
<UIcon name="i-heroicons-exclamation-triangle" />
{{ $t('landing.oasis_badge') }}
</div>
<h2 class="text-2xl md:text-3xl font-black text-highlighted leading-tight mb-4">
{{ $t('landing.oasis_title') }}<br class="hidden sm:block" />
<span class="text-orange-400">{{ $t('landing.oasis_subtitle') }}</span>
</h2>
<p class="text-muted leading-relaxed mb-6 max-w-2xl">
{{ $t('landing.oasis_desc') }}
</p>
<div class="grid sm:grid-cols-3 gap-3">
<div class="bg-elevated rounded-2xl p-4">
<div class="text-orange-400 font-black text-2xl mb-1">50+</div>
<div class="text-xs text-muted leading-relaxed">{{ $t('landing.oasis_new_domains') }}</div>
</div>
<div class="bg-elevated rounded-2xl p-4">
<div class="text-orange-400 font-black text-2xl mb-1">Offshore</div>
<div class="text-xs text-muted leading-relaxed">{{ $t('landing.oasis_offshore') }}</div>
</div>
<div class="bg-elevated rounded-2xl p-4">
<div class="text-primary-400 font-black text-2xl mb-1">208.000+</div>
<div class="text-xs text-muted leading-relaxed">{{ $t('landing.oasis_updated') }}</div>
</div>
</div>
</div>
</div>
</section>
<!-- Streak -->
<section class="py-8 px-4 bg-linear-to-b from-transparent via-orange-950/10 to-transparent">
<div v-motion :initial="{ opacity: 0, y: 50 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
class="max-w-6xl mx-auto grid lg:grid-cols-2 gap-16 items-center">
<div class="flex items-center justify-center">
<div class="relative">
<div
class="w-72 h-72 rounded-3xl bg-linear-to-br from-orange-950/60 to-yellow-900/20 border border-orange-800/20 flex flex-col items-center justify-center shadow-2xl shadow-orange-950/50 gap-2">
<UIcon name="i-heroicons-fire" class="text-orange-400 w-20 h-20" />
<div class="text-5xl font-black text-highlighted">
<AnimatedCounter :target="47" :duration="2800" />
</div>
<div class="text-orange-300 text-sm font-medium">{{ $t('landing.streak_days_free') }}</div>
</div>
<div
class="absolute -bottom-4 -right-4 bg-green-950 border border-green-700/40 rounded-2xl px-4 py-2 text-sm">
<span class="text-green-400 font-bold">+
<AnimatedCounter :target="423" :duration="3000" />
</span>
<span class="text-muted ml-1">{{ $t('landing.streak_saved') }}</span>
</div>
</div>
</div>
<div>
<div
class="inline-flex items-center gap-2 bg-orange-950/60 border border-orange-800/40 rounded-full px-3 py-1 text-xs text-orange-300 mb-6">
<UIcon name="i-heroicons-fire" />
{{ $t('landing.streak_badge') }}
</div>
<h2 class="text-4xl md:text-5xl font-black text-highlighted leading-tight mb-6">
{{ $t('landing.streak_title') }}<br />
<span class="text-orange-400">{{ $t('landing.streak_subtitle') }}</span>
</h2>
<p class="text-lg text-muted leading-relaxed">
{{ $t('landing.streak_desc') }}
</p>
</div>
</div>
</section>
<!-- SOS + Atemübungen -->
<section class="py-8 px-4">
<div v-motion :initial="{ opacity: 0, y: 50 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
class="max-w-6xl mx-auto">
<div class="text-center mb-10">
<div
class="inline-flex items-center gap-2 bg-orange-950/60 border border-orange-800/40 rounded-full px-3 py-1 text-xs text-orange-300 mb-5">
<UIcon name="i-heroicons-bolt" />
{{ $t('landing.crisis_badge') }}
</div>
<h2 class="text-4xl md:text-5xl font-black text-highlighted leading-tight">
{{ $t('landing.crisis_title') }}<br />
<span class="text-orange-400">{{ $t('landing.crisis_subtitle') }}</span>
</h2>
</div>
<div class="grid md:grid-cols-2 gap-6">
<!-- SOS -->
<div class="bg-orange-950/20 border border-orange-800/20 rounded-3xl p-6 flex flex-col gap-4">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-xl bg-orange-900/60 border border-orange-700/30 flex items-center justify-center shrink-0">
<UIcon name="i-heroicons-bolt" class="text-orange-400 text-xl" />
</div>
<div>
<div class="font-bold text-highlighted">{{ $t('landing.sos_title') }}</div>
<div class="text-xs text-orange-400">{{ $t('landing.sos_subtitle') }}</div>
</div>
</div>
<p class="text-sm text-muted leading-relaxed">
{{ $t('landing.sos_desc') }}
</p>
<div class="grid grid-cols-2 gap-2">
<div class="bg-elevated rounded-xl px-3 py-2.5 flex items-center gap-2">
<span class="text-xl">😤</span>
<span class="text-sm text-default">{{ $t('landing.sos_angry') }}</span>
</div>
<div class="bg-elevated rounded-xl px-3 py-2.5 flex items-center gap-2">
<span class="text-xl">😔</span>
<span class="text-sm text-default">{{ $t('landing.sos_sad') }}</span>
</div>
<div class="bg-elevated rounded-xl px-3 py-2.5 flex items-center gap-2">
<span class="text-xl">😰</span>
<span class="text-sm text-default">{{ $t('landing.sos_stressed') }}</span>
</div>
<div class="bg-elevated rounded-xl px-3 py-2.5 flex items-center gap-2">
<span class="text-xl">😶</span>
<span class="text-sm text-default">{{ $t('landing.sos_empty') }}</span>
</div>
</div>
</div>
<!-- Atemübungen -->
<div class="bg-primary-950/20 border border-primary-800/20 rounded-3xl p-6 flex flex-col gap-4">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-xl bg-primary-900/60 border border-primary-700/30 flex items-center justify-center shrink-0">
<UIcon name="i-heroicons-heart" class="text-primary-400 text-xl" />
</div>
<div>
<div class="font-bold text-highlighted">{{ $t('landing.breathing_title') }}</div>
<div class="text-xs text-primary-400">{{ $t('landing.breathing_subtitle') }}</div>
</div>
</div>
<p class="text-sm text-muted leading-relaxed">
{{ $t('landing.breathing_desc') }}
</p>
<div class="flex items-center justify-center py-6">
<div class="relative flex items-center justify-center">
<div
class="w-24 h-24 rounded-full bg-primary-900/20 border border-primary-700/20 absolute animate-ping opacity-20">
</div>
<div
class="w-20 h-20 rounded-full bg-primary-900/30 border border-primary-700/30 absolute opacity-40 scale-110">
</div>
<div
class="w-16 h-16 rounded-full bg-primary-900/50 border border-primary-600/40 flex items-center justify-center">
<span class="text-primary-200 text-xs font-bold">{{ $t('landing.breathing_breathe') }}</span>
</div>
</div>
</div>
<div class="flex justify-between text-[11px] px-2">
<span class="text-primary-400 font-medium">{{ $t('landing.breathing_inhale') }}</span>
<span class="text-dimmed"></span>
<span class="text-muted">{{ $t('landing.breathing_hold') }}</span>
<span class="text-dimmed"></span>
<span class="text-muted">{{ $t('landing.breathing_exhale') }}</span>
</div>
</div>
</div>
</div>
</section>
<!-- Coach + Community -->
<section class="py-8 px-4 bg-linear-to-b from-transparent via-primary-950/10 to-transparent">
<div v-motion :initial="{ opacity: 0, y: 50 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
class="max-w-6xl mx-auto grid lg:grid-cols-2 gap-16 items-center">
<div class="flex items-center justify-center">
<div class="w-72 bg-elevated border border-default rounded-3xl p-6 space-y-3 shadow-2xl">
<div class="flex items-center gap-2 mb-2">
<div class="w-8 h-8 rounded-full bg-primary-900 flex items-center justify-center">
<UIcon name="i-heroicons-cpu-chip" class="text-primary-400 text-sm" />
</div>
<span class="text-sm font-medium text-highlighted">{{ $t('landing.coach_label') }}</span>
</div>
<div v-for="msg in chatMessages" :key="msg.text" :class="msg.isBot ? 'mr-8' : 'ml-8 text-right'">
<div :class="msg.isBot
? 'bg-muted text-default rounded-2xl rounded-tl-sm'
: 'bg-primary-600 text-white rounded-2xl rounded-tr-sm'
" class="inline-block px-3 py-2 text-xs leading-relaxed max-w-[90%]">
{{ msg.text }}
</div>
</div>
</div>
</div>
<div>
<div
class="inline-flex items-center gap-2 bg-primary-950/60 border border-primary-800/40 rounded-full px-3 py-1 text-xs text-primary-300 mb-6">
<UIcon name="i-heroicons-user-group" />
{{ $t('landing.coach_badge') }}
</div>
<h2 class="text-4xl md:text-5xl font-black text-highlighted leading-tight mb-6">
{{ $t('landing.coach_title') }}<br />
<span class="text-primary-400">{{ $t('landing.coach_subtitle') }}</span>
</h2>
<p class="text-lg text-muted leading-relaxed mb-6">
{{ $t('landing.coach_desc') }}
</p>
<div class="bg-primary-950/30 border border-primary-800/20 rounded-2xl p-4 space-y-3">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-rocket-launch" class="text-primary-400" />
<span class="text-xs font-bold text-primary-300 uppercase tracking-wider">{{
$t('landing.founding_badge') }}</span>
</div>
<p class="text-sm text-muted">{{ $t('landing.founding_desc', { count: 50 }) }}</p>
<div class="flex items-center gap-3">
<div class="flex-1 bg-muted rounded-full h-1.5 overflow-hidden">
<div class="bg-linear-to-r from-primary-500 to-orange-500 h-full rounded-full" style="width: 34%">
</div>
</div>
<span class="text-xs text-muted shrink-0">{{ $t('landing.founding_slots', { current: 17, total: 50 })
}}</span>
</div>
<a href="https://apps.apple.com/app/rebreak" target="_blank" rel="noopener">
<UButton size="sm" class="w-full">
<UIcon name="i-heroicons-bolt" />
{{ $t('landing.founding_cta') }}
</UButton>
</a>
</div>
</div>
</div>
</section>
<!-- Mail Agent -->
<section class="py-8 px-4 bg-linear-to-b from-transparent via-primary-950/10 to-transparent">
<div v-motion :initial="{ opacity: 0, y: 50 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
class="max-w-6xl mx-auto grid lg:grid-cols-2 gap-16 items-center">
<div>
<div
class="inline-flex items-center gap-2 bg-primary-950/60 border border-primary-800/40 rounded-full px-3 py-1 text-xs text-primary-300 mb-6">
<UIcon name="i-heroicons-envelope" />
{{ $t('landing.mail_badge') }}
</div>
<h2 class="text-4xl md:text-5xl font-black text-highlighted leading-tight mb-6">
{{ $t('landing.mail_title') }}<br />
<span class="text-primary-400">{{ $t('landing.mail_subtitle') }}</span>
</h2>
<p class="text-lg text-muted leading-relaxed mb-6">
{{ $t('landing.mail_desc') }}
</p>
<ul class="space-y-3 text-sm text-default">
<li class="flex items-center gap-3">
<UIcon name="i-heroicons-check-circle" class="text-primary-400 shrink-0" />
{{ $t('landing.mail_feat_providers') }}
</li>
<li class="flex items-center gap-3">
<UIcon name="i-heroicons-check-circle" class="text-primary-400 shrink-0" />
{{ $t('landing.mail_feat_intervals') }}
</li>
<li class="flex items-center gap-3">
<UIcon name="i-heroicons-check-circle" class="text-primary-400 shrink-0" />
{{ $t('landing.mail_feat_privacy') }}
</li>
</ul>
</div>
<!-- Mock Mail-App UI -->
<div class="flex items-center justify-center">
<div class="w-72 bg-elevated border border-default rounded-3xl p-5 shadow-2xl space-y-4">
<div class="grid grid-cols-3 gap-2 text-center">
<div class="bg-muted rounded-xl p-2.5">
<div class="text-xl font-black text-primary-400">276</div>
<div class="text-[10px] text-muted mt-0.5">{{ $t('landing.mail_mock_blocked') }}</div>
</div>
<div class="bg-muted rounded-xl p-2.5">
<div class="text-xl font-black text-highlighted">1450</div>
<div class="text-[10px] text-muted mt-0.5">{{ $t('landing.mail_mock_scanned') }}</div>
</div>
<div class="bg-muted rounded-xl p-2.5">
<div class="text-xl font-black text-orange-400">19%</div>
<div class="text-[10px] text-muted mt-0.5">{{ $t('landing.mail_mock_rate') }}</div>
</div>
</div>
<div>
<div class="flex items-center justify-between mb-2">
<div class="text-xs text-muted font-medium">{{ $t('landing.mail_mock_accounts') }}</div>
<span class="text-xs text-primary-400 font-medium">2/5</span>
</div>
<div class="bg-muted rounded-xl p-3">
<div class="flex items-center gap-2">
<div
class="w-6 h-6 rounded-full bg-primary-900/60 border border-primary-800/30 flex items-center justify-center shrink-0">
<UIcon name="i-heroicons-envelope" class="text-primary-400 text-xs" />
</div>
<div class="flex-1 min-w-0">
<div class="text-xs text-highlighted font-medium truncate">user@example.com</div>
<div class="text-[10px] text-primary-400">4 blockiert · 06.04.</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- FINAL CTA -->
<section class="py-16 px-4 pb-24 text-center relative">
<div class="absolute inset-0 bg-linear-to-t from-primary-950/20 to-transparent pointer-events-none" />
<div class="relative max-w-2xl mx-auto">
<h2 class="text-5xl font-black text-highlighted mb-4">{{ $t('landing.final_title') }}</h2>
<p class="text-muted mb-10 text-lg">
{{ $t('landing.final_desc') }}
</p>
<a href="https://apps.apple.com/app/rebreak" target="_blank" rel="noopener">
<UButton size="xl" class="px-12">
<UIcon name="i-heroicons-bolt" />
{{ $t('landing.final_cta') }}
</UButton>
</a>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ layout: "default" });
const { t } = useI18n();
const infoSections = ref<HTMLElement | null>(null);
const showInfoSections = ref(true);
onMounted(() => {
if (window.innerWidth < 768) showInfoSections.value = false;
});
function scrollToInfo() {
showInfoSections.value = true;
nextTick(() => infoSections.value?.scrollIntoView({ behavior: "smooth" }));
}
const blockerFeatures = computed(() => [
t('landing.blocker_feat_platforms'),
t('landing.blocker_feat_updated'),
t('landing.blocker_feat_custom'),
t('landing.blocker_feat_cooldown'),
]);
const chatMessages = computed(() => [
{ text: t('landing.chat_msg_1'), isBot: false },
{ text: t('landing.chat_msg_2'), isBot: true },
{ text: t('landing.chat_msg_3'), isBot: false },
{ text: t('landing.chat_msg_4'), isBot: true },
]);
</script>

View File

@ -0,0 +1,251 @@
<template>
<div class="min-h-screen bg-default font-sans pb-16 md:pb-0">
<!-- Header -->
<div class="pt-10 pb-8 text-center px-4">
<div
class="inline-flex items-center gap-2 bg-primary-950/60 border border-primary-800/40 rounded-full px-4 py-1.5 text-sm text-primary-300 mb-6">
<UIcon name="i-heroicons-document-text" class="text-primary-400" />
Rechtliches
</div>
<h1 class="text-3xl md:text-5xl font-extrabold text-highlighted mb-3">
Nutzungsbedingungen
</h1>
<p class="text-muted text-sm">Stand: 1. Mai 2026</p>
</div>
<!-- Wichtiger Hinweis -->
<div class="px-4 max-w-3xl mx-auto mb-8">
<div class="bg-amber-950/40 border border-amber-700/40 rounded-2xl p-5">
<div class="flex items-start gap-3">
<UIcon name="i-heroicons-exclamation-triangle" class="text-amber-400 text-xl shrink-0 mt-0.5" />
<div class="text-sm text-amber-100 leading-relaxed">
<p class="font-semibold mb-2 text-amber-200">
Rebreak ist kein Ersatz für ärztliche, psychotherapeutische oder suchtmedizinische Behandlung.
</p>
<p>
Die App ist eine digitale Selbsthilfe-Begleitung. Bei akuten Krisen oder Suizidgedanken:
<strong>Telefonseelsorge 0800 1110111</strong> (kostenfrei, 24h) oder <strong>Notruf 112</strong>.
</p>
</div>
</div>
</div>
</div>
<!-- Inhaltsverzeichnis -->
<div class="px-4 max-w-3xl mx-auto mb-10">
<div class="bg-elevated border border-default rounded-2xl p-5">
<h2 class="text-base font-bold text-highlighted mb-3 flex items-center gap-2">
<UIcon name="i-heroicons-list-bullet" class="text-primary-400" />
Inhaltsverzeichnis
</h2>
<ol class="text-sm text-muted space-y-1.5 list-decimal list-inside">
<li><a href="#par1" class="hover:text-primary-400 transition-colors">Geltungsbereich und Anbieter</a></li>
<li><a href="#par2" class="hover:text-primary-400 transition-colors">Vertragsabschluss und Mindestalter</a></li>
<li><a href="#par3" class="hover:text-primary-400 transition-colors">Leistungsbeschreibung und Charakter der App</a></li>
<li><a href="#par4" class="hover:text-primary-400 transition-colors">Pflichten des Nutzers</a></li>
<li><a href="#par5" class="hover:text-primary-400 transition-colors">Preise, Abonnement und automatische Verlängerung</a></li>
<li><a href="#par6" class="hover:text-primary-400 transition-colors">Widerrufsrecht für Verbraucher</a></li>
<li><a href="#par7" class="hover:text-primary-400 transition-colors">Verfügbarkeit und Wartung</a></li>
<li><a href="#par8" class="hover:text-primary-400 transition-colors">Haftung</a></li>
<li><a href="#par9" class="hover:text-primary-400 transition-colors">Geistige Eigentumsrechte und nutzergenerierte Inhalte</a></li>
<li><a href="#par10" class="hover:text-primary-400 transition-colors">Beendigung des Vertragsverhältnisses</a></li>
<li><a href="#par11" class="hover:text-primary-400 transition-colors">Änderungen dieser Nutzungsbedingungen</a></li>
<li><a href="#par12" class="hover:text-primary-400 transition-colors">Schlussbestimmungen</a></li>
</ol>
</div>
</div>
<!-- Inhalt -->
<article class="px-4 max-w-3xl mx-auto pb-24 text-sm text-muted leading-relaxed space-y-10">
<section id="par1">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 1 Geltungsbereich und Anbieter</h2>
<p class="mb-3">
Diese Nutzungsbedingungen regeln das Vertragsverhältnis zwischen dem Anbieter und
den Nutzerinnen und Nutzern der mobilen Anwendung sowie der zugehörigen Web-Anwendung Rebreak".
</p>
<div class="bg-elevated border border-default rounded-xl p-4 mb-3 not-prose">
<p class="text-highlighted font-semibold mb-1">Chahine Brini</p>
<p>(natürliche Person, einzelkaufmännisch handelnd)</p>
<p class="mt-2">E-Mail: <a href="mailto:c.brini@icloud.com" class="text-primary-400 hover:underline">c.brini@icloud.com</a></p>
</div>
<p class="text-xs text-muted italic">
Eine Überführung in die in Gründung befindliche Raynis GmbH" ist geplant.
Nutzer werden rechtzeitig informiert.
</p>
</section>
<section id="par2">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 2 Vertragsabschluss und Mindestalter</h2>
<p class="mb-3">
Die Nutzung setzt die Einrichtung eines Nutzerkontos voraus. Mit Abschluss der
Registrierung kommt ein Nutzungsvertrag über die kostenfreien Funktionen zustande.
</p>
<p>
Die Nutzung ist Personen ab dem vollendeten <strong>16. Lebensjahr</strong> gestattet.
Minderjährige benötigen die Einwilligung eines Erziehungsberechtigten.
</p>
</section>
<section id="par3">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 3 Leistungsbeschreibung und Charakter der App</h2>
<p class="mb-3">
Rebreak ist ein digitales <strong>Selbsthilfe-Tool zur Unterstützung bei problematischem
Glücksspielverhalten</strong>. Funktionen: Domain-/URL-Filter, KI-Coach Lyra",
Streak-Zähler, Trigger-Logging, SOS-Atemübung, Community-Bereich.
</p>
<p class="mb-3">
<strong>Rebreak ist ausdrücklich kein Medizinprodukt, keine ärztliche Behandlung und
keine Therapie.</strong> Die App stellt keine Diagnosen und ersetzt keinen Arztbesuch.
Nutzer mit ausgeprägter Glücksspielproblematik werden aufgefordert, professionelle Hilfe
in Anspruch zu nehmen (BZgA: 0800 1372700).
</p>
<p>
Die durch Lyra" generierten Antworten beruhen auf einem maschinellen Sprachmodell,
können fehlerhaft sein und stellen keine fachliche Empfehlung dar.
</p>
</section>
<section id="par4">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 4 Pflichten des Nutzers</h2>
<p class="mb-3">Der Nutzer verpflichtet sich insbesondere, folgende Handlungen zu unterlassen:</p>
<ul class="list-disc list-inside mb-3 space-y-1">
<li>Schutz- und Sperrmechanismen (Domain-Filter, Tamper-Lock, Cooldown) zu umgehen;</li>
<li>Dekompilierung oder Reverse Engineering der App;</li>
<li>Beeinträchtigung der Systemintegrität durch Massenanfragen oder Schadsoftware;</li>
<li>Veröffentlichung rechtswidriger, beleidigender oder kommerziell werbender Inhalte;</li>
<li>Bewerbung von Glücksspielangeboten;</li>
<li>Weitergabe des Nutzerkontos an Dritte.</li>
</ul>
</section>
<section id="par5">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 5 Preise, Abonnement und automatische Verlängerung</h2>
<p class="mb-3">
Die Grundfunktionen (Tarif Free") sind kostenfrei. Kostenpflichtige Tarife:
</p>
<ul class="list-disc list-inside mb-3 space-y-1">
<li>Tarif Pro": 29,00 pro Jahr</li>
<li>Tarif Legend": 59,00 pro Jahr</li>
</ul>
<p class="mb-3">
Kostenpflichtige Abonnements verlängern sich automatisch, sofern der Nutzer nicht
spätestens 24 Stunden vor Ablauf kündigt. Kündigung über die Account-Verwaltung oder
den jeweiligen App-Store.
</p>
<p>
Preisänderungen werden mindestens 6 Wochen vorher per E-Mail angekündigt.
</p>
</section>
<section id="par6">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 6 Widerrufsrecht für Verbraucher</h2>
<div class="bg-elevated border border-default rounded-xl p-4 mb-4">
<p class="font-semibold text-highlighted mb-2">Widerrufsbelehrung</p>
<p class="mb-3">
Sie haben das Recht, binnen <strong>vierzehn Tagen</strong> ohne Angabe von Gründen
diesen Vertrag zu widerrufen. Die Frist beginnt ab dem Tag des Vertragsabschlusses.
</p>
<p class="mb-3">
Zur Ausübung: Chahine Brini,
<a href="mailto:c.brini@icloud.com" class="text-primary-400 hover:underline">c.brini@icloud.com</a>
</p>
<p class="font-semibold text-highlighted mb-2">Erlöschen des Widerrufsrechts</p>
<p>
Das Widerrufsrecht erlischt bei digitalen Inhalten, wenn der Anbieter mit der
Ausführung begonnen hat und der Nutzer ausdrücklich zugestimmt und seine Kenntnis
vom Erlöschen des Widerrufsrechts bestätigt hat.
</p>
</div>
</section>
<section id="par7">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 7 Verfügbarkeit und Wartung</h2>
<p>
Der Anbieter bemüht sich um größtmögliche Verfügbarkeit (Best-Effort). Eine konkrete
Verfügbarkeitsgarantie besteht nicht. Wartungsfenster werden soweit möglich
rechtzeitig angekündigt.
</p>
</section>
<section id="par8">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 8 Haftung</h2>
<p class="mb-3">
Der Anbieter haftet unbeschränkt bei Vorsatz, grober Fahrlässigkeit sowie bei
Schäden aus Verletzungen von Leben, Körper oder Gesundheit. Bei einfacher Fahrlässigkeit
ist die Haftung auf den vertragstypischen, vorhersehbaren Schaden begrenzt.
</p>
<p>
Der Anbieter haftet nicht für Schäden, die dadurch entstehen, dass der Nutzer auf
erforderliche ärztliche oder therapeutische Behandlung verzichtet.
</p>
</section>
<section id="par9">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 9 Geistige Eigentumsrechte und nutzergenerierte Inhalte</h2>
<p class="mb-3">
Alle Rechte an der App, am Design, an der Marke Rebreak" und am KI-Coach „Lyra"
stehen dem Anbieter oder seinen Lizenzgebern zu. Der Nutzer erhält ein einfaches,
nicht übertragbares Nutzungsrecht.
</p>
<p>
Nutzergenerierte Inhalte bleiben Eigentum des Nutzers. Der Nutzer räumt dem Anbieter
ein auf die Vertragsdauer beschränktes Nutzungsrecht zur Bereitstellung der App-Dienste ein.
</p>
</section>
<section id="par10">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 10 Beendigung des Vertragsverhältnisses</h2>
<p class="mb-3">
Der Nutzer kann den Free-Tarif jederzeit durch Löschung des Kontos beenden.
Kostenpflichtige Abonnements können zum Ende der laufenden Periode gekündigt werden.
</p>
<p>
Mit Beendigung werden die Nutzerinhalte gemäß Datenschutzerklärung gelöscht.
Ein Datenexport (Art. 20 DSGVO) wird vorher bereitgestellt.
</p>
</section>
<section id="par11">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 11 Änderungen dieser Nutzungsbedingungen</h2>
<p>
Änderungen werden mindestens 4 Wochen vorher per E-Mail oder In-App-Mitteilung angekündigt.
Widerspricht der Nutzer nicht innerhalb von 4 Wochen, gelten die Änderungen als angenommen.
</p>
</section>
<section id="par12">
<h2 class="text-xl font-bold text-highlighted mb-3">§ 12 Schlussbestimmungen</h2>
<p class="mb-3">
Es gilt das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts.
Gerichtsstand ist der Sitz des Anbieters, soweit der Nutzer Kaufmann ist.
</p>
<p>
Sollten einzelne Bestimmungen unwirksam sein, bleibt die Gültigkeit der übrigen
Bestimmungen unberührt.
</p>
</section>
<section class="pt-6 border-t border-default">
<p class="text-xs text-muted mb-2">
<strong class="text-highlighted">Anbieter:</strong> Chahine Brini ·
<a href="mailto:c.brini@icloud.com" class="text-primary-400 hover:underline">c.brini@icloud.com</a>
</p>
<p class="text-xs text-muted"><strong class="text-highlighted">Stand:</strong> 1. Mai 2026</p>
</section>
</article>
</div>
</template>
<script setup lang="ts">
definePageMeta({ layout: "default" });
useHead({
title: 'Nutzungsbedingungen Rebreak',
meta: [
{ name: 'description', content: 'Nutzungsbedingungen der Rebreak-App. Stand: 1. Mai 2026.' },
{ name: 'robots', content: 'index,follow' },
],
});
</script>

View File

@ -0,0 +1,312 @@
<template>
<div class="min-h-screen bg-default font-sans pb-16 md:pb-0">
<!-- Header -->
<div v-motion :initial="{ opacity: 0, y: -30 }" :visible="{ opacity: 1, y: 0, transition: { duration: 600 } }"
class="pt-10 pb-12 text-center px-4">
<div
class="inline-flex items-center gap-2 bg-amber-950/60 border border-amber-700/40 rounded-full px-4 py-1.5 text-sm text-amber-300 mb-4 animate-pulse">
<UIcon name="i-heroicons-fire" class="text-amber-400" />
{{ $t('pricing.founding_banner') }}
</div>
<div
class="inline-flex items-center gap-2 bg-primary-950/60 border border-primary-800/40 rounded-full px-4 py-1.5 text-sm text-primary-300 mb-6">
<UIcon name="i-heroicons-sparkles" class="text-primary-400" />
{{ $t('pricing.title') }}
</div>
<h1 class="text-4xl md:text-5xl font-extrabold text-highlighted mb-4">
{{ $t('pricing.subtitle_start') }}<br />
<span
class="text-transparent bg-clip-text bg-linear-to-r from-primary-400 via-primary-300 to-green-400">
{{ $t('pricing.subtitle_end') }}
</span>
</h1>
<!-- Billing Cycle Picker -->
<div class="flex items-center justify-center gap-2 mt-8">
<button v-for="opt in billingOptions" :key="opt.value" @click="billing = opt.value"
class="relative px-4 py-2 rounded-full text-sm font-semibold transition-all"
:class="billing === opt.value ? 'bg-primary-700 text-white' : 'bg-muted text-muted hover:text-highlighted'">
{{ opt.label }}
<span v-if="opt.badge"
class="absolute -top-2 -right-2 bg-green-500 text-white text-[9px] font-bold px-1.5 py-0.5 rounded-full">{{
opt.badge }}</span>
</button>
</div>
</div>
<!-- Pricing Plans -->
<div v-motion :initial="{ opacity: 0, y: 40 }"
:visible="{ opacity: 1, y: 0, transition: { duration: 700, delay: 100 } }"
class="px-4 pb-16 max-w-5xl mx-auto">
<UPricingPlans :plans="plans" />
</div>
<!-- Feature Comparison -->
<div v-motion :initial="{ opacity: 0, y: 50 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
class="px-4 pb-12 max-w-3xl mx-auto">
<div class="bg-elevated border border-purple-800/30 rounded-2xl p-6 md:p-8">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-xl bg-purple-800/30 flex items-center justify-center shrink-0 mt-0.5">
<UIcon name="i-heroicons-shield-check" class="text-purple-400 text-xl" />
</div>
<div>
<h3 class="font-bold text-highlighted text-lg mb-2">{{ $t('pricing.pro_meaning_title') }}</h3>
<p class="text-muted text-sm leading-relaxed">
{{ $t('pricing.pro_meaning_desc') }}
</p>
</div>
</div>
</div>
</div>
<!-- Feature Comparison Table -->
<div v-motion :initial="{ opacity: 0, y: 50 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
class="px-4 pb-12 max-w-4xl mx-auto">
<h2 class="text-2xl font-extrabold text-highlighted text-center mb-2">
{{ $t('pricing.comparison_title') }}
</h2>
<p class="text-muted text-center text-sm mb-10">
{{ $t('pricing.comparison_subtitle') }}
</p>
<div class="bg-elevated border border-default rounded-2xl overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-default">
<th class="text-left p-4 text-muted font-semibold">{{ $t('pricing.feature') }}</th>
<th class="p-4 text-center text-muted font-semibold text-xs">
{{ $t('pricing.free') }}
</th>
<th class="p-4 text-center font-semibold text-xs text-primary-300">Pro</th>
<th class="p-4 text-center text-purple-400 font-semibold text-xs">Legend</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in comparisonRows" :key="row.label"
:class="i % 2 === 0 ? 'bg-white/2' : ''">
<td class="p-4 text-default font-medium">{{ row.label }}</td>
<td class="p-4 text-center">
<UIcon v-if="row.free === true" name="i-heroicons-check" class="text-green-400" />
<span v-else-if="typeof row.free === 'string'" class="text-muted text-xs">{{ row.free }}</span>
<UIcon v-else name="i-heroicons-minus" class="text-dimmed" />
</td>
<td class="p-4 text-center">
<UIcon v-if="row.pro === true" name="i-heroicons-check-circle"
class="text-primary-400 text-lg" />
<span v-else-if="typeof row.pro === 'string'"
class="text-primary-300 text-xs font-semibold">{{ row.pro }}</span>
<UIcon v-else name="i-heroicons-minus" class="text-dimmed" />
</td>
<td class="p-4 text-center">
<UIcon v-if="row.legend === true" name="i-heroicons-check-circle"
class="text-purple-400 text-lg" />
<span v-else-if="typeof row.legend === 'string'"
class="text-purple-300 text-xs font-semibold">{{ row.legend }}</span>
<UIcon v-else name="i-heroicons-minus" class="text-dimmed" />
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Quotes -->
<div v-motion :initial="{ opacity: 0, y: 40 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
class="px-4 pb-24 max-w-4xl mx-auto">
<h2 class="text-2xl font-extrabold text-highlighted text-center mb-2">
{{ $t('pricing.quotes_title') }}
</h2>
<p class="text-muted text-center text-sm mb-10">{{ $t('pricing.quotes_subtitle') }}</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<UPageCard v-for="(q, i) in quotes" :key="q.author" v-motion :initial="{ opacity: 0, y: 30 }"
:visible="{ opacity: 1, y: 0, transition: { duration: 500, delay: i * 120 } }">
<div class="flex items-center gap-3 mb-4">
<UAvatar :src="q.image" :text="q.initials" size="md" class="ring-2 ring-white/10 shrink-0" />
<div>
<div class="text-highlighted text-sm font-semibold leading-tight">{{ q.author }}</div>
<div class="text-muted text-xs">{{ q.role }}</div>
</div>
</div>
<p class="text-default text-sm leading-relaxed italic">&ldquo; {{ q.text }} &rdquo;</p>
</UPageCard>
</div>
</div>
<!-- FAQ -->
<div v-motion :initial="{ opacity: 0, y: 40 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
class="px-4 pb-24 max-w-2xl mx-auto">
<h2 class="text-2xl font-extrabold text-highlighted text-center mb-8">{{ $t('pricing.faq_title') }}</h2>
<UAccordion :items="faqItems" />
</div>
<!-- CTA -->
<div v-motion :initial="{ opacity: 0, scale: 0.95 }"
:visible="{ opacity: 1, scale: 1, transition: { duration: 600 } }"
class="px-4 pb-32 max-w-xl mx-auto text-center">
<h2 class="text-3xl font-extrabold text-highlighted mb-3">{{ $t('pricing.cta_title') }}</h2>
<p class="text-muted mb-6">{{ $t('pricing.cta_desc') }}</p>
<a href="https://apps.apple.com/app/rebreak" target="_blank" rel="noopener">
<UButton size="xl" class="font-bold px-10 rounded-full">
{{ $t('pricing.cta_button') }}
<template #trailing>
<UIcon name="i-heroicons-arrow-right" />
</template>
</UButton>
</a>
</div>
</div>
</template>
<script setup lang="ts">
import type { PricingPlanProps } from "@nuxt/ui";
definePageMeta({ layout: "default" });
const { t } = useI18n();
const billing = ref<'monthly' | 'yearly'>('monthly');
const billingOptions = computed(() => [
{ value: 'monthly', label: t('pricing.billing_monthly'), badge: null },
{ value: 'yearly', label: t('pricing.billing_yearly'), badge: t('pricing.billing_save_pct') },
] as const);
const proMonthly = 3.99;
const legendMonthly = 7.99;
const proPrice = computed(() => {
if (billing.value === 'yearly') return (29 / 12).toFixed(2);
return proMonthly.toFixed(2);
});
const legendPrice = computed(() => {
if (billing.value === 'yearly') return (59 / 12).toFixed(2);
return legendMonthly.toFixed(2);
});
const billingCycleLabel = computed(() => {
if (billing.value === 'yearly') return t('pricing.billing_per_year');
return t('pricing.billing_per_month');
});
// Marketing: alle Plan-Buttons zeigen auf App-Store
const appStoreUrl = "https://apps.apple.com/app/rebreak";
const plans = computed<PricingPlanProps[]>(() => [
{
title: t('pricing.plan_free_title'),
description: t('pricing.plan_free_desc'),
price: "0€",
billingCycle: t('pricing.billing_forever'),
features: [
t('pricing.feat_free_domains'),
t('pricing.feat_free_mail'),
t('pricing.feat_coach_basic'),
t('pricing.feat_streak'),
t('pricing.feat_urge'),
t('pricing.feat_sos'),
t('pricing.feat_community'),
],
button: {
label: t('pricing.plan_free_btn'),
to: appStoreUrl,
target: "_blank",
color: "neutral" as const,
variant: "outline" as const,
},
},
{
title: t('pricing.plan_pro_title'),
description: t('pricing.plan_pro_desc'),
price: `${proPrice.value}`,
billingCycle: billingCycleLabel.value,
scale: true,
badge: t('pricing.plan_recommended'),
features: [
t('pricing.feat_all_free'),
t('pricing.feat_blocklist'),
t('pricing.feat_pro_domains'),
t('pricing.feat_pro_mail'),
t('pricing.feat_community_post'),
t('pricing.feat_buddy'),
t('pricing.feat_coach_pro'),
t('pricing.feat_urge_stats'),
],
button: {
label: t('pricing.plan_pro_btn'),
to: appStoreUrl,
target: "_blank",
},
},
{
title: t('pricing.plan_legend_title'),
description: t('pricing.plan_legend_desc'),
price: `${legendPrice.value}`,
billingCycle: billingCycleLabel.value,
features: [
t('pricing.feat_all_pro'),
t('pricing.feat_legend_domains'),
t('pricing.feat_legend_mail'),
t('pricing.feat_legend_add'),
t('pricing.feat_legend_validate'),
t('pricing.feat_legend_groups'),
t('pricing.feat_coach_legend'),
],
button: {
label: t('pricing.plan_legend_btn'),
to: appStoreUrl,
target: "_blank",
color: "neutral" as const,
variant: "subtle" as const,
},
},
]);
const comparisonRows = computed(() => [
{ label: t('pricing.comp_domains'), free: t('pricing.comp_free_domains'), pro: t('pricing.comp_pro_domains'), legend: t('pricing.comp_legend_domains') },
{ label: t('pricing.comp_mail'), free: t('pricing.comp_free_mail_val'), pro: t('pricing.comp_pro_mail_val'), legend: t('pricing.comp_legend_mail_val') },
{ label: t('pricing.comp_coach'), free: t('pricing.comp_free_coach_val'), pro: t('pricing.comp_pro_coach_val'), legend: t('pricing.comp_legend_coach_val') },
{ label: t('pricing.comp_streak'), free: true, pro: true, legend: true },
{ label: t('pricing.comp_urge'), free: true, pro: true, legend: true },
{ label: t('pricing.comp_sos'), free: true, pro: true, legend: true },
{ label: t('pricing.comp_community'), free: true, pro: true, legend: true },
{ label: t('pricing.comp_blocklist'), free: false, pro: true, legend: true },
{ label: t('pricing.comp_post'), free: false, pro: true, legend: true },
{ label: t('pricing.comp_buddy'), free: false, pro: true, legend: true },
{ label: t('pricing.comp_urge_stats'), free: false, pro: true, legend: true },
{ label: t('pricing.comp_add_domain'), free: false, pro: false, legend: true },
{ label: t('pricing.comp_validate'), free: false, pro: false, legend: true },
{ label: t('pricing.comp_groups'), free: false, pro: false, legend: true },
]);
const quotes = [
{
text: "Zwischen Reiz und Reaktion liegt ein Raum. In diesem Raum liegt unsere Macht, unsere Reaktion zu wählen.",
author: "Viktor Frankl",
role: "Psychiater & Logotherapeut",
image: "https://upload.wikimedia.org/wikipedia/commons/thumb/8/80/Viktor_Frankl2.jpg/200px-Viktor_Frankl2.jpg",
initials: "VF",
},
{
text: "Bis du das Unbewusste bewusst machst, wird es dein Leben lenken und du wirst es Schicksal nennen.",
author: "Carl Gustav Jung",
role: "Psychiater & Begründer der analytischen Psychologie",
image: "https://upload.wikimedia.org/wikipedia/commons/thumb/0/00/CGJung.jpg/200px-CGJung.jpg",
initials: "CJ",
},
{
text: "Sucht ist keine Charakterschwäche. Sie ist eine Erkrankung des Gehirns und sie ist behandelbar.",
author: "Nora Volkow",
role: "Neurowissenschaftlerin, Direktorin des NIDA",
image: "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9b/Nora_Volkow2.jpg/200px-Nora_Volkow2.jpg",
initials: "NV",
},
];
const faqItems = computed(() => [
{ label: t('pricing.faq1_q'), content: t('pricing.faq1_a') },
{ label: t('pricing.faq2_q'), content: t('pricing.faq2_a') },
{ label: t('pricing.faq3_q'), content: t('pricing.faq3_a') },
{ label: t('pricing.faq4_q'), content: t('pricing.faq4_a') },
{ label: t('pricing.faq5_q'), content: t('pricing.faq5_a') },
{ label: t('pricing.faq6_q'), content: t('pricing.faq6_a') },
]);
</script>

View File

@ -0,0 +1,212 @@
<template>
<div class="max-w-4xl mx-auto px-4 pt-8 pb-24 space-y-20">
<!-- BLOCKLIST -->
<section>
<div class="flex items-start gap-5 mb-6">
<div>
<h2 class="text-2xl font-black text-highlighted leading-tight">
{{ $t('resources.blocklist_title') }}
</h2>
<p class="text-muted text-sm mt-1">
{{ $t('resources.blocklist_desc', { count: domainCount.toLocaleString('de-DE') }) }}
</p>
</div>
</div>
<UPageCard>
<div class="flex items-center justify-between mb-4">
<span class="text-xs text-muted uppercase tracking-wider font-medium">{{ $t('resources.chart_label') }}</span>
<span class="text-sm font-bold text-primary-500">{{ domainCount.toLocaleString("de-DE") }}</span>
</div>
<ChartsBlocklistGrowth :data="chartData" :height="160" />
</UPageCard>
</section>
<!-- SOFORT-HILFE -->
<section>
<div class="flex items-start gap-5 mb-6">
<div>
<h2 class="text-2xl font-black text-highlighted leading-tight">
{{ $t('resources.hotlines_title') }}
</h2>
<p class="text-muted text-sm mt-1">
{{ $t('resources.hotlines_desc') }}
</p>
</div>
</div>
<div class="grid sm:grid-cols-3 gap-4">
<a v-for="h in hotlines" :key="h.country" :href="h.url" target="_blank" rel="noopener noreferrer"
class="block bg-elevated border border-default rounded-2xl p-5 hover:border-primary-500/40 transition-colors">
<div class="text-xs text-primary-500 font-bold mb-1">{{ h.country }}</div>
<div class="font-semibold text-highlighted text-sm mb-2">
{{ h.name }}
</div>
<div class="text-lg font-mono font-bold text-green-500">{{ h.phone }}</div>
<div class="text-xs text-muted mt-1">{{ h.hours }}</div>
</a>
</div>
</section>
<!-- SELBSTHILFE TIPPS -->
<section>
<div class="flex items-start gap-5 mb-6">
<div>
<h2 class="text-2xl font-black text-highlighted leading-tight">
{{ $t('resources.tips_title') }}
</h2>
<p class="text-muted text-sm mt-1">
{{ $t('resources.tips_desc') }}
</p>
</div>
</div>
<div class="grid sm:grid-cols-2 gap-4">
<div v-for="tip in selfHelpTips" :key="tip.title"
class="bg-elevated border border-default rounded-2xl p-5 flex gap-4">
<img :src="tip.icon" class="w-10 h-10 shrink-0 opacity-80 mt-0.5" alt="" />
<div>
<h3 class="font-bold text-highlighted text-sm mb-1.5">
{{ tip.title }}
</h3>
<p class="text-sm text-muted leading-relaxed">{{ tip.text }}</p>
</div>
</div>
</div>
</section>
<!-- WARUM GEFÄHRLICH -->
<section>
<div class="flex items-start gap-5 mb-6">
<div>
<h2 class="text-2xl font-black text-highlighted leading-tight">
{{ $t('resources.not_weak_title') }}
</h2>
<p class="text-muted text-sm mt-1">
{{ $t('resources.not_weak_desc') }}
</p>
</div>
</div>
<div class="space-y-3">
<div v-for="fact in facts" :key="fact.title"
class="flex gap-4 bg-elevated border border-default rounded-2xl p-4 items-start">
<img :src="fact.icon" class="w-9 h-9 shrink-0 opacity-80 mt-0.5" alt="" />
<div>
<div class="font-semibold text-highlighted text-sm">
{{ fact.title }}
</div>
<div class="text-sm text-muted mt-0.5">{{ fact.text }}</div>
</div>
</div>
</div>
</section>
<!-- FINAL CTA -->
<section class="text-center py-6">
<img src="/astronaut.svg" class="w-20 h-20 mx-auto mb-5 opacity-80" alt="" />
<h2 class="text-3xl font-black text-highlighted mb-2">
{{ $t('resources.cta_title') }}
</h2>
<a href="https://apps.apple.com/app/rebreak" target="_blank" rel="noopener">
<UButton size="xl" class="px-10">
<UIcon name="i-heroicons-bolt" />
{{ $t('resources.cta_button') }}
</UButton>
</a>
</section>
</div>
</template>
<script setup lang="ts">
definePageMeta({ layout: "default" });
const { t } = useI18n();
const { public: { apiBase } } = useRuntimeConfig();
const domainCount = ref(0);
const chartData = ref<{ label: string; count: number }[]>([]);
onMounted(async () => {
// Blocklist-Count vom Backend (public API, kein Auth nötig)
$fetch<{ count: number }>(`${apiBase}/api/blocklist/count`).then((r) => {
if (r?.count) domainCount.value = r.count;
}).catch(() => { });
$fetch<{
current: number;
history: { label: string; count: number }[];
}>(`${apiBase}/api/blocklist/stats`).then((stats) => {
if (stats?.current) domainCount.value = stats.current;
if (stats?.history) chartData.value = stats.history;
}).catch(() => { });
});
const hotlines = computed(() => [
{
country: t('resources.hotline_de'),
name: "BZgA check-dein-spiel.de",
phone: "0800 1372700",
hours: "MoDo 1022 Uhr, FrSo 1018 Uhr",
url: "https://www.check-dein-spiel.de",
},
{
country: t('resources.hotline_at'),
name: "Spielsuchthilfe",
phone: "0800 040 080",
hours: "24h erreichbar",
url: "https://www.spielsuchthilfe.at",
},
{
country: t('resources.hotline_ch'),
name: "Addiction Suisse",
phone: "0800 040 080",
hours: "MoFr 917 Uhr",
url: "https://www.addictionsuisse.ch",
},
]);
const selfHelpTips = computed(() => [
{
icon: "/snowflake.svg",
title: t('resources.tip_breathing'),
text: t('resources.tip_breathing_desc'),
},
{
icon: "/diary.svg",
title: t('resources.tip_15min'),
text: t('resources.tip_15min_desc'),
},
{
icon: "/walk.svg",
title: t('resources.tip_move'),
text: t('resources.tip_move_desc'),
},
{
icon: "/alert.svg",
title: t('resources.tip_triggers'),
text: t('resources.tip_triggers_desc'),
},
]);
const facts = computed(() => [
{
icon: "/brain.svg",
title: t('resources.fact1_title'),
text: t('resources.fact1_text'),
},
{
icon: "/phone-call.svg",
title: t('resources.fact2_title'),
text: t('resources.fact2_text'),
},
{
icon: "/kidneys.svg",
title: t('resources.fact3_title'),
text: t('resources.fact3_text'),
},
{
icon: "/graph.svg",
title: t('resources.fact4_title'),
text: t('resources.fact4_text'),
},
]);
</script>

1
apps/marketing/dist Symbolic link
View File

@ -0,0 +1 @@
/Users/chahinebrini/mono/rebreak-monorepo/apps/marketing/.output/public

View File

@ -0,0 +1,72 @@
export default defineNuxtConfig({
compatibilityDate: "2025-07-15",
devtools: { enabled: false },
// SPA-mode: statisch servierbar via nginx try_files /index.html
ssr: false,
app: {
htmlAttrs: { lang: "de" },
head: {
meta: [
{
name: "viewport",
content: "width=device-width, initial-scale=1",
},
],
},
},
modules: [
"@nuxt/ui",
"@nuxt/image",
"@nuxt/fonts",
"@nuxt/icon",
"@nuxtjs/i18n",
"@vueuse/motion/nuxt",
"@vueuse/nuxt",
],
fonts: {
families: [{ name: "Nunito", provider: "google" }],
},
i18n: {
locales: [
{ code: "de", name: "Deutsch", dir: "ltr", file: "de.json" },
{ code: "en", name: "English", dir: "ltr", file: "en.json" },
],
defaultLocale: "de",
strategy: "no_prefix",
// restructureDir:false verhindert dass i18n v9 den Nuxt-4-Default-Prefix
// "i18n/" vor langDir stellt. Ohne das würde es unter {rootDir}/i18n/locales/ suchen.
restructureDir: false,
langDir: "locales",
detectBrowserLanguage: {
useCookie: true,
cookieKey: "rebreak_lang",
cookieSecure: false,
fallbackLocale: "de",
redirectOn: "root",
},
},
colorMode: {
preference: "dark",
fallback: "dark",
},
css: ["~/assets/css/main.css"],
devServer: {
port: 3020,
},
runtimeConfig: {
public: {
// Backend-API für public endpoints (Blocklist-Count etc.)
// Staging: api.staging.rebreak.org | Prod: api.rebreak.org
apiBase: process.env.NUXT_PUBLIC_API_BASE ?? "https://api.staging.rebreak.org",
},
},
});

View File

@ -0,0 +1,33 @@
{
"name": "@rebreak/marketing",
"type": "module",
"private": true,
"version": "0.1.0",
"scripts": {
"dev": "nuxt dev --port 3020",
"build": "nuxt build",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@iconify-json/heroicons": "^1.2.3",
"@nuxt/fonts": "^0.11.4",
"@nuxt/icon": "^1.10.0",
"@nuxt/image": "^1.11.0",
"@nuxt/ui": "^4.5.1",
"@nuxtjs/i18n": "^9.5.6",
"@vueuse/motion": "^3.0.3",
"@vueuse/nuxt": "^14.2.1",
"chart.js": "^4.5.1",
"nuxt": "4.1.3",
"tailwindcss": "^4.1.18",
"vue": "^3.5.22",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@nuxt/devtools": "latest",
"typescript": "^5.9.3"
}
}

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512.001 512.001" style="enable-background:new 0 0 512.001 512.001;" xml:space="preserve">
<g>
<g>
<path d="M503.839,395.379l-195.7-338.962C297.257,37.569,277.766,26.315,256,26.315c-21.765,0-41.257,11.254-52.139,30.102
L8.162,395.378c-10.883,18.85-10.883,41.356,0,60.205c10.883,18.849,30.373,30.102,52.139,30.102h391.398
c21.765,0,41.256-11.254,52.14-30.101C514.722,436.734,514.722,414.228,503.839,395.379z M477.861,440.586
c-5.461,9.458-15.241,15.104-26.162,15.104H60.301c-10.922,0-20.702-5.646-26.162-15.104c-5.46-9.458-5.46-20.75,0-30.208
L229.84,71.416c5.46-9.458,15.24-15.104,26.161-15.104c10.92,0,20.701,5.646,26.161,15.104l195.7,338.962
C483.321,419.836,483.321,431.128,477.861,440.586z"/>
</g>
</g>
<g>
<g>
<rect x="241.001" y="176.01" width="29.996" height="149.982"/>
</g>
</g>
<g>
<g>
<path d="M256,355.99c-11.027,0-19.998,8.971-19.998,19.998s8.971,19.998,19.998,19.998c11.026,0,19.998-8.971,19.998-19.998
S267.027,355.99,256,355.99z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 463 463" style="enable-background:new 0 0 463 463;" xml:space="preserve">
<g>
<path d="M151.245,222.446C148.054,237.039,135.036,248,119.5,248c-4.142,0-7.5,3.357-7.5,7.5s3.358,7.5,7.5,7.5
c23.774,0,43.522-17.557,46.966-40.386c14.556-1.574,27.993-8.06,38.395-18.677c2.899-2.959,2.85-7.708-0.109-10.606
c-2.958-2.897-7.707-2.851-10.606,0.108C184.947,202.829,172.643,208,159.5,208c-26.743,0-48.5-21.757-48.5-48.5
c0-4.143-3.358-7.5-7.5-7.5s-7.5,3.357-7.5,7.5C96,191.715,120.119,218.384,151.245,222.446z"/>
<path d="M183,287.5c0-4.143-3.358-7.5-7.5-7.5c-35.014,0-63.5,28.486-63.5,63.5c0,0.362,0.013,0.725,0.019,1.088
C109.23,344.212,106.39,344,103.5,344c-4.142,0-7.5,3.357-7.5,7.5s3.358,7.5,7.5,7.5c26.743,0,48.5,21.757,48.5,48.5
c0,4.143,3.358,7.5,7.5,7.5s7.5-3.357,7.5-7.5c0-26.611-16.462-49.437-39.731-58.867c-0.178-1.699-0.269-3.418-0.269-5.133
c0-26.743,21.757-48.5,48.5-48.5C179.642,295,183,291.643,183,287.5z"/>
<path d="M439,223.5c0-17.075-6.82-33.256-18.875-45.156c1.909-6.108,2.875-12.426,2.875-18.844
c0-30.874-22.152-56.659-51.394-62.329C373.841,91.6,375,85.628,375,79.5c0-19.557-11.883-36.387-28.806-43.661
C317.999,13.383,287.162,0,263.5,0c-13.153,0-24.817,6.468-32,16.384C224.317,6.468,212.653,0,199.5,0
c-23.662,0-54.499,13.383-82.694,35.839C99.883,43.113,88,59.943,88,79.5c0,6.128,1.159,12.1,3.394,17.671
C62.152,102.841,40,128.626,40,159.5c0,6.418,0.965,12.735,2.875,18.844C30.82,190.244,24,206.425,24,223.5
c0,13.348,4.149,25.741,11.213,35.975C27.872,270.087,24,282.466,24,295.5c0,23.088,12.587,44.242,32.516,55.396
C56.173,353.748,56,356.626,56,359.5c0,31.144,20.315,58.679,49.79,68.063C118.611,449.505,141.965,463,167.5,463
c27.995,0,52.269-16.181,64-39.674c11.731,23.493,36.005,39.674,64,39.674c25.535,0,48.889-13.495,61.71-35.437
c29.475-9.385,49.79-36.92,49.79-68.063c0-2.874-0.173-5.752-0.516-8.604C426.413,339.742,439,318.588,439,295.5
c0-13.034-3.872-25.413-11.213-36.025C434.851,249.241,439,236.848,439,223.5z M167.5,448c-21.029,0-40.191-11.594-50.009-30.256
c-0.973-1.849-2.671-3.208-4.688-3.751C88.19,407.369,71,384.961,71,359.5c0-3.81,0.384-7.626,1.141-11.344
c0.702-3.447-1.087-6.92-4.302-8.35C50.32,332.018,39,314.626,39,295.5c0-8.699,2.256-17.014,6.561-24.379
C56.757,280.992,71.436,287,87.5,287c4.142,0,7.5-3.357,7.5-7.5s-3.358-7.5-7.5-7.5C60.757,272,39,250.243,39,223.5
c0-14.396,6.352-27.964,17.428-37.221c2.5-2.09,3.365-5.555,2.14-8.574C56.2,171.869,55,165.744,55,159.5
c0-26.743,21.757-48.5,48.5-48.5s48.5,21.757,48.5,48.5c0,4.143,3.358,7.5,7.5,7.5s7.5-3.357,7.5-7.5
c0-33.642-26.302-61.243-59.421-63.355C104.577,91.127,103,85.421,103,79.5c0-13.369,8.116-24.875,19.678-29.859
c0.447-0.133,0.885-0.307,1.308-0.527C127.568,47.752,131.447,47,135.5,47c12.557,0,23.767,7.021,29.256,18.325
c1.81,3.727,6.298,5.281,10.023,3.47c3.726-1.809,5.28-6.296,3.47-10.022c-6.266-12.903-18.125-22.177-31.782-25.462
C165.609,21.631,184.454,15,199.5,15c13.509,0,24.5,10.99,24.5,24.5v97.051c-6.739-5.346-15.25-8.551-24.5-8.551
c-4.142,0-7.5,3.357-7.5,7.5s3.358,7.5,7.5,7.5c13.509,0,24.5,10.99,24.5,24.5v180.279c-9.325-12.031-22.471-21.111-37.935-25.266
c-3.999-1.071-8.114,1.297-9.189,5.297c-1.075,4.001,1.297,8.115,5.297,9.189C206.8,343.616,224,366.027,224,391.5
C224,422.654,198.654,448,167.5,448z M395.161,339.807c-3.215,1.43-5.004,4.902-4.302,8.35c0.757,3.718,1.141,7.534,1.141,11.344
c0,25.461-17.19,47.869-41.803,54.493c-2.017,0.543-3.716,1.902-4.688,3.751C335.691,436.406,316.529,448,295.5,448
c-31.154,0-56.5-25.346-56.5-56.5c0-2.109-0.098-4.2-0.281-6.271c0.178-0.641,0.281-1.314,0.281-2.012V135.5
c0-13.51,10.991-24.5,24.5-24.5c4.142,0,7.5-3.357,7.5-7.5s-3.358-7.5-7.5-7.5c-9.25,0-17.761,3.205-24.5,8.551V39.5
c0-13.51,10.991-24.5,24.5-24.5c15.046,0,33.891,6.631,53.033,18.311c-13.657,3.284-25.516,12.559-31.782,25.462
c-1.81,3.727-0.256,8.214,3.47,10.022c3.726,1.81,8.213,0.257,10.023-3.47C303.733,54.021,314.943,47,327.5,47
c4.053,0,7.933,0.752,11.514,2.114c0.422,0.22,0.86,0.393,1.305,0.526C351.883,54.624,360,66.13,360,79.5
c0,5.921-1.577,11.627-4.579,16.645C322.302,98.257,296,125.858,296,159.5c0,4.143,3.358,7.5,7.5,7.5s7.5-3.357,7.5-7.5
c0-26.743,21.757-48.5,48.5-48.5s48.5,21.757,48.5,48.5c0,6.244-1.2,12.369-3.567,18.205c-1.225,3.02-0.36,6.484,2.14,8.574
C417.648,195.536,424,209.104,424,223.5c0,26.743-21.757,48.5-48.5,48.5c-4.142,0-7.5,3.357-7.5,7.5s3.358,7.5,7.5,7.5
c16.064,0,30.743-6.008,41.939-15.879c4.306,7.365,6.561,15.68,6.561,24.379C424,314.626,412.68,332.018,395.161,339.807z"/>
<path d="M359.5,240c-15.536,0-28.554-10.961-31.745-25.554C358.881,210.384,383,183.715,383,151.5c0-4.143-3.358-7.5-7.5-7.5
s-7.5,3.357-7.5,7.5c0,26.743-21.757,48.5-48.5,48.5c-13.143,0-25.447-5.171-34.646-14.561c-2.898-2.958-7.647-3.007-10.606-0.108
s-3.008,7.647-0.109,10.606c10.402,10.617,23.839,17.103,38.395,18.677C315.978,237.443,335.726,255,359.5,255
c4.142,0,7.5-3.357,7.5-7.5S363.642,240,359.5,240z"/>
<path d="M335.5,328c-2.89,0-5.73,0.212-8.519,0.588c0.006-0.363,0.019-0.726,0.019-1.088c0-35.014-28.486-63.5-63.5-63.5
c-4.142,0-7.5,3.357-7.5,7.5s3.358,7.5,7.5,7.5c26.743,0,48.5,21.757,48.5,48.5c0,1.714-0.091,3.434-0.269,5.133
C288.462,342.063,272,364.889,272,391.5c0,4.143,3.358,7.5,7.5,7.5s7.5-3.357,7.5-7.5c0-26.743,21.757-48.5,48.5-48.5
c4.142,0,7.5-3.357,7.5-7.5S339.642,328,335.5,328z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@ -0,0 +1 @@
<svg id="Layer_1" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m383.975 256.43c.632-2.527.958-5.148.957-7.816v-39.044c.001-8.571-3.354-16.646-9.447-22.739-6.092-6.093-14.167-9.448-22.737-9.448-6.869 0-13.242 2.163-18.475 5.843-4.746-11.866-16.36-20.269-29.9-20.269-6.549 0-12.646 1.965-17.734 5.338-4.179-12.931-16.333-22.312-30.637-22.312-12.31 0-23.026 6.945-28.439 17.123-5.485-4.34-12.412-6.933-19.935-6.933-17.748 0-32.187 14.438-32.187 32.186v16.009c-24.119 4.082-46.964 21.588-48.778 48.544l-2.585 38.392c-2.556 37.951 19.173 56.095 36.633 70.675 14.233 11.885 22.966 19.835 23.119 32.89v74.142c0 4.418 3.582 8 8 8h167.183c4.418 0 8-3.582 8-8v-71.079c16.195-17.732 24.232-36.08 25.908-59.072 1.583-21.703-2.597-45.801-7.887-76.311zm-31.228-63.047c4.297 0 8.354 1.691 11.424 4.761 3.07 3.071 4.761 7.128 4.761 11.425v39.045c0 4.297-1.69 8.354-4.761 11.424-3.07 3.07-7.127 4.761-11.426 4.761-4.296 0-8.354-1.69-11.424-4.761s-4.762-7.128-4.762-11.426c0-.004 0-.008 0-.012s0-.009 0-.013v-39.012c0-.001 0-.002 0-.003s0-.002 0-.003c.001-8.925 7.262-16.186 16.188-16.186zm-48.375-14.426c8.925 0 16.187 7.261 16.188 16.186v53.472c-.001 8.925-7.262 16.186-16.187 16.186-8.926 0-16.187-7.262-16.187-16.188v-53.47c.001-8.925 7.261-16.186 16.186-16.186zm-53.977 95.004c-.127-.13-.246-.263-.375-.392-.564-.565-1.155-1.106-1.75-1.642 10.884-2.672 19.764-10.483 23.917-20.706v7.581c0 8.926-7.261 16.188-16.186 16.188-1.937 0-3.814-.358-5.606-1.029zm-10.582-95.79c0-8.925 7.262-16.187 16.188-16.187 8.925 0 16.186 7.262 16.186 16.187v46.984c-5.167-12.716-17.648-21.71-32.198-21.71h-.175v-25.274zm-32.186-5.998c8.926 0 16.187 7.261 16.187 16.187v15.085h-32.374v-15.086c0-8.925 7.261-16.186 16.187-16.186zm143.385 288.837h-151.183v-58.219c95.813-.092 136.204-.085 151.183-.049zm4.538-74.252c-1.708-.008-104.409-.016-156.537.034-3.264-16.39-15.833-26.895-28.048-37.094-16.936-14.142-32.932-27.5-30.925-57.318l2.585-38.392c1.456-21.621 23.308-34.542 43.593-34.542h53.771c10.336 0 18.744 8.408 18.744 18.743s-8.408 18.744-18.744 18.744h-57.945c-4.418 0-8 3.582-8 8s3.582 8 8 8h15.124c19.649 0 33.621 4.017 41.526 11.938.349.35.684.708 1.009 1.073.03.035.059.07.09.104 9.116 10.31 9.088 26.272 9.058 40.42l-.003 2.415c0 4.418 3.582 8 8 8s8-3.582 8-8l.003-2.382c.023-11.134.049-24.059-4.179-35.853 10.312-1.506 19.053-7.914 23.768-16.78 5.485 4.339 12.413 6.933 19.935 6.934 9.633-.001 18.291-4.254 24.194-10.982.462.524.941 1.036 1.44 1.534 6.093 6.093 14.168 9.448 22.738 9.447 6.594 0 12.89-1.993 18.22-5.683 8.731 51.015 12.025 81.231-15.417 111.64zm-122.116 45.146c0 6.365-5.16 11.525-11.525 11.525s-11.524-5.16-11.524-11.525 5.159-11.524 11.524-11.524 11.525 5.159 11.525 11.524zm17.13-345.892v-43.022c0-4.418 3.582-8 8-8s8 3.582 8 8v43.022c0 4.418-3.582 8-8 8s-8-3.582-8-8zm-119.271-8.15c-2.21-3.826-.898-8.719 2.928-10.928 3.828-2.208 8.72-.897 10.928 2.928l21.512 37.258c2.21 3.826.898 8.719-2.928 10.928-1.26.727-2.636 1.073-3.992 1.073-2.766 0-5.454-1.435-6.936-4.001zm-37.671 108.419c-1.481 2.566-4.171 4.001-6.936 4.001-1.357 0-2.732-.346-3.992-1.073l-37.258-21.511c-3.826-2.209-5.138-7.102-2.928-10.928 2.208-3.827 7.102-5.138 10.928-2.928l37.258 21.511c3.827 2.209 5.138 7.101 2.928 10.928zm-34.6 102.145h-43.022c-4.418 0-8-3.582-8-8s3.582-8 8-8h43.022c4.418 0 8 3.582 8 8s-3.582 8-8 8zm32.037 86.833c2.21 3.827.898 8.719-2.928 10.928l-37.259 21.511c-1.26.727-2.636 1.073-3.992 1.073-2.766 0-5.454-1.435-6.936-4.001-2.21-3.827-.898-8.719 2.928-10.928l37.259-21.511c3.828-2.21 8.72-.898 10.928 2.928zm258.723-265.575 21.511-37.258c2.208-3.827 7.102-5.138 10.928-2.928 3.826 2.209 5.138 7.102 2.928 10.928l-21.511 37.258c-1.481 2.566-4.171 4.001-6.936 4.001-1.357 0-2.732-.346-3.992-1.073-3.826-2.209-5.138-7.102-2.928-10.928zm71.16 81.037c-2.21-3.826-.898-8.719 2.928-10.928l37.259-21.511c3.829-2.21 8.72-.897 10.928 2.928 2.21 3.826.898 8.719-2.928 10.928l-37.259 21.512c-1.26.727-2.636 1.073-3.992 1.073-2.766 0-5.454-1.435-6.936-4.002zm83.058 94.833c0 4.418-3.582 8-8 8h-43.021c-4.418 0-8-3.582-8-8s3.582-8 8-8h43.021c4.418 0 8 3.581 8 8zm-34.508 123.656c-1.481 2.566-4.171 4.001-6.936 4.001-1.357 0-2.732-.346-3.992-1.073l-37.258-21.511c-3.826-2.209-5.138-7.102-2.928-10.928 2.208-3.827 7.101-5.139 10.928-2.928l37.258 21.511c3.827 2.209 5.138 7.102 2.928 10.928z"/></svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1 @@
<svg id="Diary" enable-background="new 0 0 64 64" height="512" viewBox="0 0 64 64" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m31.62239 23.62939c-1.74505 0-6.80223-2.29461-8.96227-7.97515-.70931-1.86833-.71278-3.62901-.01042-5.09191.73448-1.54103 1.99769-2.59066 3.49444-2.96138 1.4273-.34814 2.9171-.05556 4.09436.81349.57821.42541 1.02967.85343 1.38389 1.24064.35422-.38721.80568-.81436 1.38736-1.2415 1.17552-.86819 2.67228-1.16424 4.08915-.81262 1.49849.36985 2.76256 1.41948 3.46753 2.8789.02084.04341.05556.13544.06945.18058.66156 1.36392.65808 3.1246-.05035 4.99293v.00087c-2.16178 5.68054-7.21809 7.97515-8.96314 7.97515zm-4.29231-14.3945c-.25351 0-.50702.03039-.75879.0929-.99668.2457-1.81017.92983-2.29114 1.9265-.64246 1.35784-.32904 2.79121.04167 3.76879 1.91956 5.04589 6.34383 6.82828 7.30057 6.82828s5.38101-1.78238 7.2997-6.82914c.37158-.97758.68413-2.41095.07119-3.68979-.50876-1.07481-1.32311-1.75894-2.31979-2.00551-.91333-.22399-1.85965-.03559-2.60976.51831-.88555.65114-1.40646 1.30575-1.72075 1.74158-.33338.46187-1.1078.46187-1.44119 0-.31602-.43756-.83693-1.09304-1.71727-1.74071-.54522-.40111-1.19288-.61121-1.85444-.61121zm12.42373 6.10334h.01736zm1.21198 35.11114h-18.68594c-1.58183 0-2.86935-1.28665-2.86935-2.86848v-2.16005c0-1.58183 1.28752-2.86848 2.86935-2.86848h18.68595c1.58183 0 2.86848 1.28665 2.86848 2.86848v2.16005c0 1.58183-1.28665 2.86848-2.86849 2.86848zm-18.68594-6.11897c-.60165 0-1.09131.48966-1.09131 1.09044v2.16005c0 .60078.48966 1.09044 1.09131 1.09044h18.68595c.60078 0 1.09044-.48966 1.09044-1.09044v-2.16005c0-.60078-.48966-1.09044-1.09044-1.09044zm36.34185-32.83901c-.35651-1.85376-1.8833-3.2514-3.70081-3.2514-.73999 0-1.42999.22998-2.01001.64001v-4.48999c.00001-2.42004-1.95995-4.39001-4.37994-4.39001h-40c-3.38 0-6.13 2.75-6.13 6.13v52.87c0 .01727.02753.57794.07129.84314.38977 2.37012 2.60455 4.15686 5.15002 4.15686h41.20062c2.25726 0 4.09259-1.83539 4.09259-4.09265v-5.03613l.78546 1.92877c.20996.5.69.83002 1.21997.83002.53003 0 1-.33002 1.21002-.84003l2.15997-5.27997c.039-.0968.05487-.20129.08844-.30005h.00159c.08997-.22998.15002-.46997.21002-.70996.00049-.00238-.00055-.00476-.00012-.00714.07159-.36328.11011-.73517.11011-1.11285v-13.89594c.65961-.46161 1.09406-1.22412 1.09406-2.0885v-6.07379c0-.86438-.43445-1.62689-1.09406-2.08844v-.53955c.14984.03168.30469.0495.46375.0495h.19104c1.23627 0 2.24335-1.00623 2.24335-2.24341v-7.47424c0-1.77106-1.28949-3.23896-2.97736-3.53425zm-5.71081.82862c0-1.27002.90002-2.30005 2.01001-2.30005 1.10004 0 2 1.03003 2 2.30005v12.45105h-4.01001zm-48.72998-6.19001c0-2.40002 1.95001-4.35004 4.35004-4.35004h1.16998v52.1925h-2.29572c-1.19165 0-2.32733.4314-3.2243 1.19373zm46.95654 51.97846h-42.5758c-.49139 0-.88904.39764-.88904.88904s.39764.88904.88904.88904h42.57581v.02081c0 1.27625-1.03833 2.31458-2.31458 2.31458h-41.20063c-1.68512 0-3.14539-1.146-3.3963-2.66705-.16064-.97583.09723-1.92218.72754-2.66534.61469-.72406 1.508-1.13904 2.45172-1.13904h43.73224zm.00348-8.72846v4.59247h-39.66003v-52.1925h37.05005c1.42999 0 2.60999 1.17004 2.60999 2.61005v20.38104h-1.07306c-3.08032 0-5.58765 2.50641-5.58765 5.58765 0 3.08118 2.50732 5.58765 5.58765 5.58765h1.07306v13.43364zm3.77997 5.68-1.56915-3.85004h3.13837zm2-5.68c0 .01703-.00458.03302-.00482.04999h-4.00024c-.00024-.01697-.00494-.03296-.00494-.04999v-13.43365h4.01001v13.43365zm1.09607-15.98443c0 .42633-.34729.77271-.77271.77271h-7.17639c-2.10101 0-3.80963-1.70856-3.80963-3.80957s1.70862-3.80963 3.80963-3.80963h7.17639c.42542 0 .77271.34644.77271.77271zm1.80407-10.89569c0 .25696-.20837.46533-.46533.46533h-.19104c-.25415 0-.45929-.20435-.46375-.45746v-9.16107c.65729.27386 1.12012.92346 1.12012 1.67896zm-10.48756 6.9702c-.49092 0-.88889.39797-.88889.8889s.39797.8889.88889.8889.8889-.39797.8889-.8889-.39798-.8889-.8889-.8889z"/></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -0,0 +1,2 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 512 512"><path d="M444.046,249.649a217.455,217.455,0,0,0-21.937-55.124,7,7,0,0,0-7.014-3.491l-37.831,5.006c-1.665-2.279-3.387-4.524-5.146-6.708a7,7,0,0,0-10.279-.683l-35.49,33.761a7,7,0,0,0-.756,9.3,115.362,115.362,0,1,1-207.114,76.215,7,7,0,0,0-7.827-6.548L62.18,307.215a7,7,0,0,0-6.145,7.47c.213,2.865.51,5.8.886,8.773L25.976,345.826a7,7,0,0,0-2.7,7.347,217.255,217.255,0,0,0,21.909,55.095A6.982,6.982,0,0,0,52.2,411.76l37.843-5a176.783,176.783,0,0,0,34.223,35.253l-6.077,37.659a7,7,0,0,0,3.3,7.112,218.632,218.632,0,0,0,54.474,23.458,7.014,7.014,0,0,0,7.421-2.488l23.212-30.284a177.107,177.107,0,0,0,49.122.679l22.365,30.945a7,7,0,0,0,7.349,2.7,217.414,217.414,0,0,0,55.124-21.938,7,7,0,0,0,3.491-7.014l-5.027-37.806a176.406,176.406,0,0,0,35.256-34.251l37.654,6.077a6.992,6.992,0,0,0,7.107-3.288,217.4,217.4,0,0,0,23.463-54.484,7,7,0,0,0-2.487-7.42l-30.283-23.212a176.95,176.95,0,0,0,.679-49.118L441.347,257A7,7,0,0,0,444.046,249.649Zm-45.252,20.8a7,7,0,0,0-2.826,6.718,163.138,163.138,0,0,1-.734,53.059,7,7,0,0,0,2.634,6.792l29.824,22.856a203.417,203.417,0,0,1-18.259,42.4l-37.091-5.984a6.987,6.987,0,0,0-6.746,2.754,162.479,162.479,0,0,1-38.077,36.988,7,7,0,0,0-2.944,6.676l4.954,37.232a203.387,203.387,0,0,1-42.9,17.078l-22.035-30.483a7,7,0,0,0-6.714-2.821,163.165,163.165,0,0,1-53.061-.734,7,7,0,0,0-6.789,2.636l-22.856,29.822a204.61,204.61,0,0,1-42.4-18.264l5.983-37.085a7,7,0,0,0-2.749-6.743,162.87,162.87,0,0,1-36.965-38.079,7,7,0,0,0-6.674-2.945l-37.26,4.932a203.317,203.317,0,0,1-17.054-42.883l30.481-22.028a7,7,0,0,0,2.824-6.72q-.4-2.678-.716-5.324l34.5-4.155a129.363,129.363,0,0,0,77.337,104.062A129.363,129.363,0,0,0,340.422,228.35l25.431-24.192q1.226,1.673,2.4,3.368a7.008,7.008,0,0,0,6.664,2.946l37.273-4.927a203.452,203.452,0,0,1,17.078,42.9ZM44.936,284.721a7,7,0,0,0,8.886,6.509l48.9-13.691a7,7,0,0,0,5.1-6.331A115.344,115.344,0,0,1,251.924,166.6a115.364,115.364,0,0,1,26.724,10.609,7,7,0,0,0,8.918-1.859L317.613,136.4a7,7,0,0,0-1.918-10.267c-1.933-1.169-3.963-2.345-6.067-3.516l.259-38.15a7,7,0,0,0-4.348-6.53,225.516,225.516,0,0,0-28.2-9.427,219.927,219.927,0,0,0-29.224-5.475,7.016,7.016,0,0,0-6.961,3.6l-18.3,33.469a176.911,176.911,0,0,0-48.649,6.822l-26.8-27.162a7.006,7.006,0,0,0-7.683-1.55,217.747,217.747,0,0,0-51.138,30.079,7,7,0,0,0-2.38,7.465l10.724,36.629a176.367,176.367,0,0,0-29.622,39.209l-38.148-.252h-.043a7,7,0,0,0-6.476,4.34A218.112,218.112,0,0,0,7.762,253.086a7,7,0,0,0,3.6,6.96l33.469,18.3C44.827,280.426,44.864,282.557,44.936,284.721ZM26.8,227.392a203.113,203.113,0,0,1,7.059-22.018l37.558.254h.053a7.006,7.006,0,0,0,6.2-3.749,162.451,162.451,0,0,1,31.988-42.343,7,7,0,0,0,1.9-7.037l-10.561-36.091A203.736,203.736,0,0,1,140.786,93l26.416,26.76a7,7,0,0,0,7.061,1.76,163.056,163.056,0,0,1,52.542-7.366,7.059,7.059,0,0,0,6.31-3.638l18.031-32.966a205.807,205.807,0,0,1,22.667,4.516,210.4,210.4,0,0,1,22.035,7.095l-.248,37.566a7,7,0,0,0,3.717,6.226q1.17.621,2.311,1.246l-21.573,27.963A129.391,129.391,0,0,0,94.239,265.372l-35.381,9.906q.008-.441.019-.877a7,7,0,0,0-3.637-6.315L22.273,250.053A204.938,204.938,0,0,1,26.8,227.392ZM378.214,75.877l-46.659,66.871-46.63,66.869a7,7,0,0,0,9.748,9.748l66.87-46.631,66.87-46.658a7,7,0,0,0,.948-10.69L409.142,95.14,388.9,74.929a7,7,0,0,0-10.689.948Zm6.7,14.863,28.633,28.631L318.869,185.42ZM504,154.66l-8.674-29.311a7,7,0,0,0-9.732-4.331l-88.131,42.124A7,7,0,0,0,401.4,176.4l96.8-12.812a7,7,0,0,0,5.8-8.928Zm-60.651,2.064,40.878-19.533,4.021,13.589Zm-110.51-46.2a7,7,0,0,0,8.31-3.691l21.064-44.056,21.033-44.079a7,7,0,0,0-4.329-9.729L349.631.291a7,7,0,0,0-8.928,5.8l-12.813,96.8A7,7,0,0,0,332.838,110.52Zm20.673-94.477,13.566,4.018L347.566,60.942Zm-93.6,186.064a15.976,15.976,0,0,0-11.678-4.9h-29.14a15.974,15.974,0,0,0-11.678,4.9,12.609,12.609,0,0,0-3.5,9.5l6.067,93.983c.5,7.575,7.184,13.509,15.206,13.509h16.952c8.062,0,14.73-5.956,15.179-13.521l6.093-93.956A12.616,12.616,0,0,0,259.911,202.107ZM243.357,304.636a1.405,1.405,0,0,1-1.218.459H225.187a1.647,1.647,0,0,1-1.233-.421l-6.017-93.2a2.61,2.61,0,0,1,1.156-.259h29.14a2.61,2.61,0,0,1,1.156.259Zm-9.694,31.669a25.428,25.428,0,1,0,25.427,25.427A25.47,25.47,0,0,0,233.663,336.305Zm0,36.851a11.424,11.424,0,1,1,11.424-11.424A11.45,11.45,0,0,1,233.663,373.156Z"/></svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1 @@
<svg id="Capa_1" enable-background="new 0 0 511.98 511.98" height="512" viewBox="0 0 511.98 511.98" width="512" xmlns="http://www.w3.org/2000/svg"><g><path d="m483.24 77.21v164.4c0 40.69-18.65 87.92-52.5 132.99-39.45 52.51-97.56 98.86-168.05 134.04l-6.7 3.34-6.7-3.34c-70.49-35.18-128.6-81.53-168.05-134.04-33.85-45.07-52.5-92.3-52.5-132.99v-164.4l110.56-77.21h233.38z" fill="#00358f"/><path d="m483.24 77.21v164.4c0 40.69-18.65 87.92-52.5 132.99-39.45 52.51-97.56 98.86-168.05 134.04l-6.7 3.34v-511.98h116.69z" fill="#012453"/><path d="m353.8 60h-195.62l-69.44 48.49v133.12c0 45.75 45.01 135.15 167.25 202.78 122.24-67.63 167.25-157.03 167.25-202.78v-133.12z" fill="#00b3fe"/><path d="m423.24 108.49v133.12c0 45.75-45.01 135.15-167.25 202.78v-384.39h97.81z" fill="#0274f9"/><path d="m358.52 45h-205.06l-79.72 55.67v140.94c0 29.26 14.82 66.44 40.65 102 31.45 43.3 77.9 82.69 134.34 113.91l7.26 4.02 7.26-4.02c56.44-31.22 102.89-70.61 134.34-113.91 25.83-35.56 40.65-72.74 40.65-102v-140.94zm49.72 196.61c0 39.91-40.71 121.4-152.25 185.56-111.54-64.16-152.25-145.65-152.25-185.56v-125.3l59.16-41.31h186.18l59.16 41.31z" fill="#eff5fa"/><path d="m438.24 100.67v140.94c0 29.26-14.82 66.44-40.65 102-31.45 43.3-77.9 82.69-134.34 113.91l-7.26 4.02v-34.37c111.54-64.16 152.25-145.65 152.25-185.56v-125.3l-59.16-41.31h-93.09v-30h102.53z" fill="#c6e1ec"/><path d="m351.75 174.24-95.76 98.42-.2.21-24.35 25.02-71.21-66.6 20.49-21.91 49.72 46.51 25.35-26.04.2-.21 74.26-76.32z" fill="#eff5fa"/><path d="m351.75 174.24-95.76 98.42v-43.02l74.26-76.32z" fill="#c6e1ec"/></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1 @@
<svg height="512" viewBox="0 0 48 48" width="512" xmlns="http://www.w3.org/2000/svg"><g id="Icons"><path d="m3 24a1.059 1.059 0 0 0 .136-.009 77.375 77.375 0 0 0 21.274-6.079 77.1 77.1 0 0 0 18.838-12.028l-1.218 4.874a1 1 0 0 0 .727 1.212 1.025 1.025 0 0 0 .243.03 1 1 0 0 0 .969-.758l2-8a1 1 0 0 0 -.969-1.242h-8a1 1 0 0 0 0 2h5.369a75.2 75.2 0 0 1 -18.779 12.088 75.363 75.363 0 0 1 -20.725 5.921 1 1 0 0 0 .135 1.991z"/><path d="m45 44h-1v-27a1 1 0 0 0 -1-1h-10a1 1 0 0 0 -1 1v27h-2v-21a1 1 0 0 0 -1-1h-10a1 1 0 0 0 -1 1v21h-2v-15a1 1 0 0 0 -1-1h-10a1 1 0 0 0 -1 1v15h-1a1 1 0 0 0 0 2h42a1 1 0 0 0 0-2zm-11-26h8v26h-8zm-14 6h8v20h-8zm-14 6h8v14h-8z"/></g></svg>

After

Width:  |  Height:  |  Size: 665 B

View File

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<g>
<g>
<path d="M492.305,168.159c-1.823-3.718-6.314-5.256-10.037-3.43c-3.719,1.824-5.254,6.318-3.43,10.037
C493.849,205.363,497,236.841,497,257.86c0,28.404-5.893,81.948-45.354,121.409c-12.603,12.603-29.359,19.544-47.183,19.544
c-17.822,0-34.579-6.941-47.182-19.544c-12.604-12.603-19.544-29.359-19.544-47.183c0-11.363,2.832-22.286,8.143-31.982
c0.156-0.238,0.293-0.487,0.42-0.741c2.952-5.229,6.618-10.097,10.98-14.459c2.609-2.609,4.777-5.456,6.505-8.489
c0.027-0.048,0.056-0.093,0.082-0.142c0.427-0.756,0.831-1.522,1.202-2.301c7.775,4.727,12.43,11.955,14.72,16.429
c1.328,2.593,3.956,4.083,6.682,4.083c1.15,0,2.318-0.266,3.412-0.826c3.688-1.888,5.145-6.407,3.258-10.094
c-5.766-11.259-14.337-19.834-24.505-24.687c0.009-0.339,0.024-0.676,0.024-1.017c0-1.875-0.135-3.719-0.396-5.527
c10.34-4.817,19.023-13.436,24.876-24.868c1.888-3.687,0.429-8.206-3.258-10.094c-3.689-1.889-8.206-0.43-10.094,3.258
c-2.503,4.89-7.74,12.907-16.679,17.525c-0.341-0.552-0.698-1.097-1.07-1.635c-0.088-0.13-0.177-0.258-0.273-0.382
c-1.317-1.861-2.807-3.642-4.486-5.32c-26.017-26.017-26.017-68.349,0-94.366c12.602-12.603,29.359-19.544,47.182-19.544
s34.58,6.941,47.184,19.545c3.212,3.212,6.308,6.633,9.202,10.169c2.623,3.206,7.35,3.677,10.554,1.053
c3.206-2.624,3.678-7.349,1.054-10.554c-3.208-3.92-6.641-7.713-10.203-11.275c-31.866-31.866-83.715-31.865-115.579,0
c-26.887,26.887-31.087,68-12.602,99.332l-10.147-3.547c-10.773-3.766-19.819-11.298-25.472-21.211l-14.007-24.559
c-3.979-6.976-6.081-14.907-6.081-22.938V66.745c0-12.207-9.358-22.21-21.306-22.772c-6.193-0.29-12.049,1.897-16.517,6.16
c-4.405,4.203-6.932,10.104-6.932,16.192v86.598c0,8.03-2.103,15.962-6.081,22.938l-14.005,24.557
c-5.654,9.913-14.701,17.446-25.473,21.211l-10.098,3.529c7.369-12.447,11.309-26.682,11.309-41.526
c0-21.83-8.501-42.353-23.937-57.789c-15.436-15.436-35.959-23.938-57.789-23.938c-21.83,0-42.354,8.501-57.789,23.938
C6.463,169.129,0,227.135,0,257.86c0,26.67,5.009,77.662,38.557,119.521c2.59,3.232,7.311,3.753,10.543,1.162
c3.232-2.59,3.752-7.311,1.162-10.543C19.581,329.719,15,282.562,15,257.86c0-28.404,5.892-81.947,45.354-121.409
c12.603-12.603,29.359-19.544,47.183-19.544c17.823,0,34.58,6.941,47.183,19.544c12.603,12.603,19.544,29.359,19.544,47.183
c0,17.824-6.94,34.58-19.544,47.183c-1.676,1.676-3.163,3.453-4.479,5.31c-0.095,0.123-0.183,0.25-0.27,0.378
c-0.375,0.543-0.736,1.092-1.08,1.649c-8.939-4.618-14.176-12.635-16.68-17.526c-1.888-3.687-6.408-5.144-10.094-3.257
c-3.687,1.888-5.145,6.407-3.257,10.094c5.854,11.432,14.537,20.051,24.876,24.867c-0.261,1.809-0.395,3.653-0.395,5.527
c0,0.341,0.016,0.678,0.024,1.018c-10.169,4.853-18.74,13.427-24.505,24.687c-1.888,3.687-0.429,8.206,3.257,10.094
c1.095,0.56,2.262,0.826,3.412,0.826c2.726,0,5.354-1.491,6.682-4.083c2.291-4.474,6.945-11.702,14.721-16.429
c0.39,0.819,0.817,1.624,1.269,2.418c0.056,0.101,0.113,0.202,0.173,0.3c1.702,2.93,3.816,5.684,6.345,8.213
c4.36,4.36,8.024,9.225,10.975,14.451c0.13,0.259,0.27,0.513,0.429,0.756c5.308,9.694,8.139,20.616,8.139,31.976
c0,17.823-6.94,34.58-19.544,47.183c-21.278,21.277-54.337,25.684-80.392,10.714c-3.592-2.063-8.176-0.825-10.24,2.767
c-2.063,3.592-0.824,8.176,2.767,10.24c12.658,7.271,26.662,10.812,40.581,10.812c21.182,0,42.165-8.2,57.89-23.926
c15.436-15.436,23.937-35.959,23.937-57.789c0-8.279-1.229-16.368-3.589-24.065l1.057,0.331
c6.618,2.073,11.065,8.125,11.065,15.06v122.263c0,12.339,10.038,22.377,22.377,22.377c12.339,0,22.378-10.038,22.378-22.377
V323.413c0-26.602-17.056-49.817-42.442-57.77l-1.235-0.387l3.946-1.379c20.968-7.33,38.576-21.993,49.58-41.287l3.588-6.291
l3.587,6.289c11.005,19.296,28.613,33.959,49.582,41.289l3.957,1.383l-1.223,0.383c-25.386,7.952-42.441,31.168-42.441,57.77
v122.263c0,12.339,10.038,22.377,22.377,22.377s22.377-10.038,22.377-22.377V323.413c0-6.935,4.446-12.988,11.065-15.06
l1.057-0.331c-2.36,7.697-3.589,15.786-3.589,24.065c0,21.83,8.501,42.354,23.936,57.789
c15.935,15.935,36.858,23.901,57.79,23.899c20.925-0.002,41.858-7.968,57.789-23.899C505.537,346.592,512,288.584,512,257.86
C512,235.253,508.583,201.34,492.305,168.159z M227.551,323.413v122.263c0,4.068-3.31,7.377-7.378,7.377
c-4.068,0-7.377-3.309-7.377-7.377V323.413c0-13.527-8.673-25.331-21.582-29.375l-13.761-4.31
c-2.9-4.768-6.318-9.257-10.209-13.416l7.911-2.765l20.469,6.411C214.721,285.939,227.551,303.403,227.551,323.413z
M272.604,215.157l-8.803-15.436c-1.598-2.8-4.593-4.539-7.816-4.538c-3.223,0.001-6.217,1.74-7.812,4.539l-8.805,15.436
c-9.21,16.15-23.949,28.423-41.5,34.558L159,263.302c-0.423-1.748-0.662-3.558-0.662-5.443c0-3.95,0.937-7.605,2.786-10.928
L193,235.789c14.189-4.96,26.105-14.882,33.553-27.94l14.005-24.557c5.267-9.236,8.051-19.737,8.051-30.369V66.325
c0-2.037,0.812-3.933,2.286-5.339c1.473-1.405,3.416-2.125,5.457-2.03c3.866,0.182,7.011,3.676,7.011,7.789v86.176
c0,10.632,2.784,21.134,8.052,30.37l14.006,24.558c7.445,13.057,19.361,22.98,33.553,27.94l31.906,11.152
c1.845,3.321,2.779,6.972,2.779,10.918c0,1.888-0.24,3.701-0.664,5.451l-38.89-13.593
C296.555,243.582,281.816,231.309,272.604,215.157z M320.784,294.038c-12.908,4.044-21.581,15.848-21.581,29.375v122.263
c0,4.068-3.31,7.377-7.377,7.377c-4.067,0-7.377-3.309-7.377-7.377V323.413c0-20.01,12.83-37.474,31.926-43.455l20.457-6.407
l7.917,2.767c-3.888,4.157-7.304,8.644-10.203,13.409L320.784,294.038z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -0,0 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 56">
<defs>
<radialGradient id="rb-glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#ffcc44" stop-opacity="1"/>
<stop offset="25%" stop-color="#ff9900" stop-opacity="0.85"/>
<stop offset="55%" stop-color="#ff6600" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#ff3300" stop-opacity="0"/>
</radialGradient>
<linearGradient id="rb-metal" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#c8d8ea"/>
<stop offset="30%" stop-color="#8898aa"/>
<stop offset="60%" stop-color="#566070"/>
<stop offset="100%" stop-color="#a0b4c4"/>
</linearGradient>
<linearGradient id="rb-shine" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="rgba(220,240,255,0.55)"/>
<stop offset="100%" stop-color="rgba(220,240,255,0)"/>
</linearGradient>
</defs>
<circle cx="70" cy="28" r="26" fill="url(#rb-glow)" opacity="0.95"/>
<g stroke="#ffcc44" stroke-linecap="round">
<line x1="70" y1="2" x2="70" y2="54" stroke-width="1.6"/>
<line x1="44" y1="28" x2="96" y2="28" stroke-width="1.6"/>
<line x1="51" y1="9" x2="89" y2="47" stroke-width="1.2"/>
<line x1="89" y1="9" x2="51" y2="47" stroke-width="1.2"/>
<line x1="58" y1="4" x2="56" y2="1" stroke-width="0.9" opacity="0.7"/>
<line x1="82" y1="4" x2="84" y2="1" stroke-width="0.9" opacity="0.7"/>
<line x1="94" y1="16" x2="97" y2="14" stroke-width="0.9" opacity="0.7"/>
<line x1="94" y1="40" x2="97" y2="42" stroke-width="0.9" opacity="0.7"/>
<line x1="82" y1="52" x2="84" y2="55" stroke-width="0.9" opacity="0.7"/>
<line x1="58" y1="52" x2="56" y2="55" stroke-width="0.9" opacity="0.7"/>
<line x1="46" y1="40" x2="43" y2="42" stroke-width="0.9" opacity="0.7"/>
<line x1="46" y1="16" x2="43" y2="14" stroke-width="0.9" opacity="0.7"/>
</g>
<circle cx="61" cy="4" r="1.4" fill="#ffcc44" opacity="0.85"/>
<circle cx="79" cy="4" r="1.1" fill="#ffcc44" opacity="0.75"/>
<circle cx="96" cy="10" r="1.0" fill="#ffcc44" opacity="0.70"/>
<circle cx="98" cy="45" r="1.3" fill="#ffcc44" opacity="0.75"/>
<circle cx="62" cy="51" r="1.5" fill="#ffcc44" opacity="0.80"/>
<circle cx="44" cy="42" r="1.0" fill="#ffcc44" opacity="0.65"/>
<ellipse cx="24" cy="28" rx="16" ry="10.5" fill="none" stroke="url(#rb-metal)" stroke-width="6"/>
<ellipse cx="24" cy="24" rx="11" ry="4.5" fill="none" stroke="url(#rb-shine)" stroke-width="3"/>
<ellipse cx="46" cy="28" rx="16" ry="10.5" fill="none" stroke="url(#rb-metal)" stroke-width="6"/>
<ellipse cx="46" cy="24" rx="11" ry="4.5" fill="none" stroke="url(#rb-shine)" stroke-width="3"/>
<ellipse cx="94" cy="28" rx="16" ry="10.5" fill="none" stroke="url(#rb-metal)" stroke-width="6"/>
<ellipse cx="94" cy="24" rx="11" ry="4.5" fill="none" stroke="url(#rb-shine)" stroke-width="3"/>
<ellipse cx="116" cy="28" rx="16" ry="10.5" fill="none" stroke="url(#rb-metal)" stroke-width="6"/>
<ellipse cx="116" cy="24" rx="11" ry="4.5" fill="none" stroke="url(#rb-shine)" stroke-width="3"/>
<circle cx="70" cy="28" r="5" fill="#ffaa00"/>
<circle cx="70" cy="28" r="2.8" fill="#ffdd88"/>
<circle cx="70" cy="28" r="1.2" fill="#fffbe0"/>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 505.709 505.709" style="enable-background:new 0 0 505.709 505.709;" xml:space="preserve">
<g>
<g>
<path d="M427.554,71.862c-99.206-95.816-256.486-95.816-355.692,0c-98.222,101.697-95.405,263.762,6.292,361.984
c99.206,95.816,256.486,95.816,355.692,0C532.068,332.15,529.251,170.084,427.554,71.862z M421.814,421.814l-0.085-0.085
c-93.352,93.267-244.636,93.198-337.903-0.154S-9.372,176.94,83.98,83.673s244.636-93.198,337.903,0.153
c44.799,44.84,69.946,105.643,69.905,169.028C491.792,316.225,466.622,377.002,421.814,421.814z"/>
</g>
</g>
<g>
<g>
<path d="M396.641,325.729l-47.957-47.787c-10.884-10.91-28.552-10.931-39.462-0.047c-0.016,0.016-0.031,0.031-0.047,0.047
l-27.477,27.477c-2.079,2.084-5.355,2.372-7.765,0.683c-15.039-10.51-29.117-22.333-42.069-35.328
c-11.6-11.574-22.271-24.042-31.915-37.291c-1.748-2.38-1.494-5.68,0.597-7.765l28.16-28.16
c10.872-10.893,10.872-28.531,0-39.424l-47.957-47.957c-11.051-10.565-28.458-10.565-39.509,0l-15.189,15.189
c-22.939,22.681-31.128,56.359-21.163,87.04c7.436,22.447,17.947,43.755,31.232,63.317c11.96,17.934,25.681,34.628,40.96,49.835
c16.611,16.73,35.011,31.581,54.869,44.288c21.83,14.245,45.799,24.904,70.997,31.573c6.478,1.597,13.126,2.399,19.797,2.389
c22.871-0.14,44.752-9.346,60.843-25.6l13.056-13.056C407.513,354.26,407.513,336.622,396.641,325.729z M384.557,353.514
c-0.011,0.011-0.022,0.023-0.034,0.034l0.085-0.256l-13.056,13.056c-16.775,16.987-41.206,23.976-64.427,18.432
c-23.395-6.262-45.635-16.23-65.877-29.525c-18.806-12.019-36.234-26.069-51.968-41.899
c-14.477-14.371-27.483-30.151-38.827-47.104c-12.408-18.242-22.229-38.114-29.184-59.051
c-7.973-24.596-1.366-51.585,17.067-69.717l15.189-15.189c4.223-4.242,11.085-4.257,15.326-0.034
c0.011,0.011,0.023,0.022,0.034,0.034l47.957,47.957c4.242,4.223,4.257,11.085,0.034,15.326
c-0.011,0.011-0.022,0.022-0.034,0.034l-28.16,28.16c-8.08,7.992-9.096,20.692-2.389,29.867
c10.185,13.978,21.456,27.131,33.707,39.339c13.659,13.718,28.508,26.197,44.373,37.291c9.167,6.394,21.595,5.316,29.525-2.56
l27.221-27.648c4.223-4.242,11.085-4.257,15.326-0.034c0.011,0.011,0.022,0.022,0.034,0.034l48.043,48.128
C388.765,342.411,388.78,349.272,384.557,353.514z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1 @@
<svg id="Capa_1" enable-background="new 0 0 385.239 385.239" height="512" viewBox="0 0 385.239 385.239" width="512" xmlns="http://www.w3.org/2000/svg"><g><g id="Snowflake"><path d="m363.332 218.21c2.859 7.039-.529 15.063-7.567 17.923l-38.229 15.547 35.381 21.26c6.598 3.768 8.893 12.172 5.125 18.77s-12.172 8.893-18.77 5.125c-.178-.101-.353-.207-.526-.316l-35.379-21.257 4.221 41.069c.654 7.57-4.953 14.238-12.523 14.892-7.399.639-13.97-4.709-14.847-12.084l-6.329-61.59-67.611-40.615v78.874l49.531 37.148c6.079 4.559 7.311 13.183 2.752 19.262s-13.183 7.311-19.262 2.752l-33.02-24.765v41.276c0 7.599-6.16 13.759-13.759 13.759s-13.759-6.16-13.759-13.759v-41.276l-33.02 24.765c-6.079 4.559-14.703 3.327-19.262-2.752s-3.327-14.703 2.752-19.262l49.531-37.148v-78.874l-67.608 40.625-6.329 61.59c-.72 7.012-6.623 12.346-13.672 12.354-.473.002-.946-.022-1.417-.073-7.554-.771-13.053-7.519-12.282-15.073.001-.005.001-.01.002-.016l4.221-41.069-35.379 21.257c-6.424 4.059-14.922 2.141-18.98-4.283-4.059-6.424-2.141-14.922 4.283-18.98.173-.109.348-.215.526-.316l35.381-21.26-38.229-15.547c-6.993-2.973-10.252-11.051-7.28-18.044 2.906-6.836 10.713-10.131 17.638-7.445l57.354 23.323 68.82-41.358-68.826-41.354-57.354 23.33c-7.084 2.748-15.055-.766-17.803-7.851-2.687-6.925.609-14.733 7.445-17.638l38.229-15.547-35.382-21.267c-6.599-3.768-8.893-12.172-5.125-18.77 3.768-6.599 12.172-8.893 18.77-5.125.178.101.353.207.526.316l35.379 21.257-4.221-41.07c-.775-7.559 4.724-14.316 12.283-15.091s14.316 4.724 15.091 12.283l6.329 61.59 67.609 40.622v-78.874l-49.531-37.148c-6.081-4.557-7.317-13.18-2.76-19.261s13.18-7.317 19.261-2.76c.003.002.006.005.01.007l33.02 24.765v-41.274c0-7.599 6.16-13.759 13.759-13.759s13.759 6.16 13.759 13.759v41.276l33.02-24.765c6.079-4.559 14.703-3.327 19.262 2.752s3.327 14.703-2.752 19.262l-49.531 37.146v78.875l67.608-40.626 6.329-61.59c.897-7.546 7.741-12.935 15.286-12.039 7.375.876 12.723 7.448 12.084 14.847l-4.221 41.069 35.379-21.257c6.424-4.058 14.922-2.141 18.98 4.283s2.141 14.922-4.283 18.98c-.173.109-.348.215-.526.316l-35.381 21.26 38.229 15.547c6.993 2.973 10.252 11.051 7.279 18.044-2.906 6.836-10.713 10.131-17.638 7.445l-57.354-23.323-68.82 41.358 68.826 41.354 57.354-23.323c7.027-2.866 15.046.507 17.912 7.534.003.009.007.017.011.026z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" height="512" image-rendering="optimizeQuality" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><g id="Layer_x0020_1"><g id="_231561928"><g><g><g id="_231561448"><path d="m265 113c-31 0-56-25-56-56 0-32 25-57 56-57s56 25 56 57c0 31-25 56-56 56zm0-99c-23 0-42 19-42 43 0 23 19 42 42 42 24 0 43-19 43-42 0-24-19-43-43-43z"/></g><g id="_231561064"><path d="m296 512c-19 0-35-16-35-36 0-2 0-6 0-12 0-19 1-73-1-83l-67-61c-18-13-12-51-11-55 3-16 7-38 10-53-2 1-3 3-3 4l-21 65c-4 14-17 23-31 23-4 0-7-1-10-2-17-5-27-24-21-41l26-83c3-9 30-26 44-34 16-10 47-25 72-25 22 0 36 11 40 31 2 9-4 57-7 77-2 17-5 34-7 46-2 11-3 17-4 21l49 44c1 1 14 13 13 30v108c0 20-16 36-36 36zm-94-326c2 0 4 1 5 3 2 2 2 5 1 9 0 2 0 5-1 8-1 6-3 15-4 25-4 18-7 36-7 36-2 11-4 36 5 42h1l69 63c3 3 6 9 4 92v12c0 6 2 11 6 15 4 5 10 7 15 7 12 0 22-10 22-22v-108c0-1 0-1 0-1 1-10-9-18-9-19l-52-47c-2-3-3-6-1-9 2-5 7-31 12-69 5-40 7-66 7-70-4-14-12-20-27-20-38 0-97 42-102 50l-27 82c-3 10 2 21 12 24 2 1 4 1 6 1 8 0 15-6 18-13l21-65c3-11 21-24 23-25 1 0 2-1 3-1z"/></g><g id="_231561976"><path d="m149 511c-4 0-7 0-11-2-9-3-16-10-20-19-4-8-4-19-1-28l50-140c0-2 2-4 5-4 2-1 4 0 6 2l32 33 12 13c2 2 3 4 2 7l-40 113c-3 9-10 17-18 21-6 3-11 4-17 4zm27-173-46 129c-2 6-2 12 1 18 2 5 6 9 12 11 5 2 11 2 16-1 6-3 10-8 12-14l38-109-9-9z"/></g><g id="_231561280"><path d="m378 305c-5 0-10-1-13-4l-72-52c-3-2-4-5-3-7l9-54c0-2 2-4 4-5s5-1 7 1l88 67c11 9 13 26 4 40-6 9-15 14-24 14zm-74-65 69 50c1 1 3 1 5 1 4 0 9-3 12-8 5-7 5-17 0-21l-79-60z"/></g></g><g id="_231561160"><path d="m438 512h-364c-3 0-7-3-7-7s4-7 7-7h364c3 0 7 3 7 7s-4 7-7 7z"/></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -74,27 +74,42 @@ Flow-Headern als `appId: org.rebreak.app`.
Flows benoetigen Test-User-Credentials. **Nie** hardcoden — immer als Env-Vars uebergeben.
```bash
export E2E_TEST_USER=claude-android-test
export E2E_TEST_PASSWORD=<Passwort aus Infisical>
```
Oder via Infisical:
### Option A: direktes `--env` Flag
```bash
infisical run -- maestro test apps/rebreak-native/.maestro/auth/signin.yaml
maestro test \
--env=E2E_TEST_USER=admin \
--env=E2E_TEST_PASSWORD=<Passwort aus Infisical> \
apps/rebreak-native/.maestro/auth/email-signin.yaml
```
### Option B: Infisical Wrapper
```bash
infisical run -- maestro test apps/rebreak-native/.maestro/auth/email-signin.yaml
```
Voraussetzung: Infisical-Projekt hat `E2E_TEST_USER` und `E2E_TEST_PASSWORD` als Secrets.
Variablen die Flows erwarten:
| Var | Beschreibung |
|----------------------|-----------------------------------------------|
| `E2E_TEST_USER` | Username ohne @rebreak.internal |
|----------------------|-------------------------------------------------------|
| `E2E_TEST_USER` | Username-Teil der E-Mail (ohne @rebreak.internal) |
| `E2E_TEST_PASSWORD` | Passwort des Test-Users auf Staging |
Wichtig: Der Backend-Server haengt `@rebreak.internal` automatisch an den Username.
In den Flows steht deshalb `${E2E_TEST_USER}@rebreak.internal` als E-Mail-Input.
### Test-Account (aktueller Stand)
- **`admin@rebreak.org`** — email-basierter Account, Passwort in Infisical als `E2E_TEST_PASSWORD`
Dann: `E2E_TEST_USER=admin`
- **`charioanouar@gmail.com`** — Google OAuth only, kein Passwort → kann NICHT fuer Maestro-Email-Login genutzt werden
- **`claude-android-test@rebreak.internal`** — dedizierter CI-Test-Account (Erstellung via Service-Role noetig wenn nicht vorhanden)
Empfehlung: `admin`-Account fuer lokale Flow-Tests nutzen.
---
## 4. Flows ausfuehren
@ -195,12 +210,25 @@ Test-User muss **vorab** auf dem Staging-Backend existieren:
## 8. Flow-Uebersicht
| Flow | Was wird geprueft |
|-----------------------------------|----------------------------------------------------------|
| `auth/signin.yaml` | App startet, Login funktioniert, Home-Feed sichtbar |
| `urge/start-session.yaml` | SOS-Button im Dropdown erreichbar, Lyra-Screen laedt |
| `community/post.yaml` | ComposeCard oeffnet, Text-Input funktioniert, Post sendet|
| `profile/view-profile.yaml` | Profil-Navigation via Dropdown, ProfileScreen laedt |
| Flow | Was wird geprueft | Stabil? |
|---|---|---|
| `auth/signin.yaml` | App startet, Login via Email+Pw, Home-Feed sichtbar | Ja (text-selektoren) |
| `auth/email-signin.yaml` | Identisch — aktuelle Version mit besseren Kommentaren | Ja |
| `urge/start-session.yaml` | SOS im Dropdown erreichbar, Lyra-Screen laedt | Koordinaten-Fallback |
| `urge/sos-flow.yaml` | SOS → Lyra-Chat → "Atemübung" Chip → BreathingDrawer | LLM-abhaengig |
| `community/post.yaml` | ComposeCard, Text-Input, Submit | Ja |
| `community/create-post.yaml` | Identisch — aktuelle Version | Ja |
| `profile/view-profile.yaml` | Profil-Navigation, ProfileHeader, StatsBar | Koordinaten-Fallback |
| `profile/view-and-edit.yaml` | Profil → Edit → Nickname aendern → Speichern | Koordinaten-Fallback |
| `profile/demographics.yaml` | DemographicsAccordion toggle, WheelPicker oeffnet | Text-selektoren |
| `settings/dark-theme.yaml` | Settings → Theme → Dunkel | Native-Menu-Limitation |
**Koordinaten-Fallback** = Flow nutzt `point: "x%, y%"` fuer Avatar-Button, weil kein `testID` vorhanden.
Bricht wenn Header-Layout geaendert wird. Betroffene testIDs: `TODO_TESTIDS.md`.
**Native-Menu-Limitation** = `@react-native-menu/menu` (UIMenu auf iOS) kann Maestro moeglicherweise
nicht interagieren — Flow koennte an diesem Step haengen. Wenn `settings/dark-theme.yaml` immer
an "Systemstandard" haengt: bekanntes Problem, kein Maestro-Bug, sondern iOS-Restriktion.
---

View File

@ -0,0 +1,104 @@
# TODO: testID additions needed for stable Maestro selectors
These are components that need `testID="..."` added by the UI agent (rebreak-native-ui domain).
Ahmed does NOT add these — list is for coordination.
Priority: HIGH = flow currently uses coordinate fallback or is skipped.
Priority: MEDIUM = flow works via text selector but will break on i18n locale change.
Priority: LOW = nice to have, flow is stable enough without it.
---
## HIGH — Coordinate fallbacks (breaks on layout change)
| Component | File | Recommended testID | Used by flow |
|---|---|---|---|
| Avatar Pressable (menu trigger) | `components/AppHeader.tsx` line ~109 | `header-avatar-btn` | all flows that open dropdown |
| Nickname edit Pressable in ProfileHeader | `components/profile/ProfileHeader.tsx` | `profile-edit-nickname-btn` | `profile/view-and-edit.yaml` |
| Photo/avatar area tap in ProfileHeader | `components/profile/ProfileHeader.tsx` | `profile-edit-avatar-btn` | `profile/view-and-edit.yaml` |
AppHeader snippet (line ~109):
```tsx
<Pressable
testID="header-avatar-btn" // <-- add this
onPress={() => setMenuOpen(true)}
...
>
```
---
## HIGH — SOS screen send button (no text, no testID)
| Component | File | Recommended testID | Used by flow |
|---|---|---|---|
| Send/submit Pressable in chat input area | `app/urge.tsx` | `sos-send-btn` | `urge/sos-flow.yaml` |
The send icon Pressable has no text and no testID. Current flow cannot reliably tap it.
---
## HIGH — Demographics field row Pressables
Each row in DemographicsAccordion that opens a WheelPickerModal is a Pressable with no testID.
Currently matched via hardcoded German label text (stable, but fragile if labels change).
| Field | File | Recommended testID |
|---|---|---|
| Geburtsjahr row | `components/profile/DemographicsAccordion.tsx` | `demographics-birth-year-row` |
| Geschlecht row | `components/profile/DemographicsAccordion.tsx` | `demographics-gender-row` |
| Familienstand row | `components/profile/DemographicsAccordion.tsx` | `demographics-marital-row` |
| Berufsstatus row | `components/profile/DemographicsAccordion.tsx` | `demographics-employment-row` |
| Bundesland row | `components/profile/DemographicsAccordion.tsx` | `demographics-bundesland-row` |
| Stadt / Landkreis row | `components/profile/DemographicsAccordion.tsx` | `demographics-city-row` |
---
## MEDIUM — Auth screen inputs (currently matched via i18n placeholder text)
| Component | File | Recommended testID | Risk |
|---|---|---|---|
| Email TextInput | `app/(auth)/signin.tsx` line ~139 | `auth-email-input` | breaks if de.json placeholder changes |
| Password TextInput | `app/(auth)/signin.tsx` line ~151 | `auth-password-input` | same |
| Submit Pressable | `app/(auth)/signin.tsx` line ~173 | `auth-signin-btn` | breaks if t('auth.signin') changes |
---
## MEDIUM — ProfileEdit screen nickname input
| Component | File | Recommended testID |
|---|---|---|
| Nickname TextInput | `app/profile/edit.tsx` line ~295 | `profile-nickname-input` |
| Save Pressable | `app/profile/edit.tsx` line ~154 | `profile-save-btn` |
---
## MEDIUM — ComposeCard share button
| Component | File | Recommended testID | Risk |
|---|---|---|---|
| Share/Teilen Pressable | `components/ComposeCard.tsx` line ~165 | `compose-share-btn` | breaks if t('community.share') changes |
---
## LOW — Settings screen rows
Theme selection uses @react-native-menu/menu (native iOS UIMenu).
Maestro may not interact with native UIMenu popovers — coordinate taps on the anchor
Pressable are the only option without testID. If the native menu approach proves unreliable,
a fallback custom picker with testID would be needed.
| Component | File | Recommended testID |
|---|---|---|
| Theme menu anchor Pressable | `app/settings.tsx` | `settings-theme-picker` |
| Language menu anchor Pressable | `app/settings.tsx` | `settings-language-picker` |
---
## Notes
- Adding testID to a Pressable/TextInput does NOT require any logic change — it is a
metadata prop only. Safe for UI agent to add without Ahmed review.
- RiveAvatar in urge.tsx: no testID needed — Maestro cannot assert animation states.
SOS screen is verified via the chat TextInput placeholder instead.
- NotificationsDropdown: not tested in current flow suite — no testID needed yet.

View File

@ -0,0 +1,44 @@
# auth/email-signin.yaml
# Tests: App starts → sign-in screen loads → email+password login succeeds → Home-Feed visible.
# Pre-requisite: App installed, E2E_TEST_USER account exists on staging backend.
# Env-Vars: E2E_TEST_USER (username without @rebreak.internal), E2E_TEST_PASSWORD
# Expected outcome: "ReBreak" headline visible in AppHeader after login.
# Note: No testIDs on signin inputs — text selectors match i18n keys from de.json.
# Run with --env=E2E_LOCALE=de if CI device locale may differ.
appId: org.rebreak.app
---
- launchApp:
clearState: true
# Splash / font-load can take a moment
- waitForAnimationToEnd:
timeout: 5000
# Signin screen must appear immediately — no auth state after clearState.
# TextInput placeholder = t('auth.emailPlaceholder') = "E-Mail" (de.json)
- assertVisible:
text: "E-Mail"
- tapOn:
text: "E-Mail"
- inputText: ${E2E_TEST_USER}@rebreak.internal
# Password input placeholder = t('auth.passwordPlaceholder') = "Passwort" (de.json)
- tapOn:
text: "Passwort"
- inputText: ${E2E_TEST_PASSWORD}
# Submit button text = t('auth.signin') = "Anmelden" (de.json)
# Button is disabled until both fields have content — typing above enables it.
- tapOn:
text: "Anmelden"
# Supabase auth + /api/auth/me call — allow network round-trip
- waitForAnimationToEnd:
timeout: 10000
# AppHeader shows t('appHeader.appName') = "ReBreak" (hardcoded fallback, de.json).
# This text only appears in the authenticated Home layout.
- assertVisible:
text: "ReBreak"

View File

@ -0,0 +1,72 @@
# community/create-post.yaml
# Tests: Login → Home-Feed → ComposeCard → type text → publish → ComposeCard resets.
# Pre-requisite: App installed. Test-user exists on staging.
# Env-Vars: E2E_TEST_USER, E2E_TEST_PASSWORD
# Expected outcome: After tapping "Teilen", ComposeCard resets to idle state
# (placeholder text visible again). Post is created in staging DB.
#
# NOTE: This flow creates a real post on staging. No automatic cleanup.
# Delete via Service-Role or Supabase dashboard after test runs.
#
# ComposeCard placeholder = t('community.compose_placeholder') = "Was bewegt dich gerade?" (de.json)
# Share button = t('community.share') = "Teilen" (de.json)
appId: org.rebreak.app
---
- launchApp:
clearState: true
- waitForAnimationToEnd:
timeout: 5000
# --- Auth ---
- assertVisible:
text: "E-Mail"
- tapOn:
text: "E-Mail"
- inputText: ${E2E_TEST_USER}@rebreak.internal
- tapOn:
text: "Passwort"
- inputText: ${E2E_TEST_PASSWORD}
- tapOn:
text: "Anmelden"
- waitForAnimationToEnd:
timeout: 10000
# --- Home-Feed ---
- assertVisible:
text: "ReBreak"
# ComposeCard sits at top of Home feed (/(app)/index.tsx).
# Scroll up first to make sure we're at the top.
- scrollUntilVisible:
element:
text: "Was bewegt dich gerade?"
direction: UP
timeout: 4000
- assertVisible:
text: "Was bewegt dich gerade?"
# Tap placeholder to focus the TextInput (sets focused=true, showActions=true)
- tapOn:
text: "Was bewegt dich gerade?"
- waitForAnimationToEnd:
timeout: 1000
# Type post content — unique enough to find in DB for cleanup
- inputText: "[E2E] Maestro-Testpost — bitte ignorieren."
# After text input: action row appears with "Teilen" button
- assertVisible:
text: "Teilen"
# Submit. API call: POST /api/community/post → returns 200 → queryClient invalidates
- tapOn:
text: "Teilen"
- waitForAnimationToEnd:
timeout: 8000
# After success: cancel() is called → content reset → ComposeCard shows placeholder again
- assertVisible:
text: "Was bewegt dich gerade?"

View File

@ -0,0 +1,118 @@
# profile/demographics.yaml
# Tests: Login → Profile → DemographicsAccordion toggle → WheelPicker opens for Geburtsjahr
# → dismiss → Gender picker opens → dismiss → verify accordion still expanded.
# Pre-requisite: App installed. Test-user exists on staging.
# Env-Vars: E2E_TEST_USER, E2E_TEST_PASSWORD
# Expected outcome: Accordion expands, pickers open without crash.
#
# LIMITATION: WheelPickerModal is a Modal + ScrollView — Maestro can tap within it
# but cannot reliably assert a specific wheel item is selected. This flow only verifies
# the UI path opens and dismisses cleanly (no crash). Full field-fill test requires
# testIDs on each picker trigger row.
#
# NOTE: The accordion header uses hardcoded text (not i18n):
# "ANONYMER BEITRAG ZUR FORSCHUNG" — safe to match as static string.
#
# BLOCKER on Avatar tap: coordinate-based (93%, 6%). See TODO_TESTIDS.md.
appId: org.rebreak.app
---
- launchApp:
clearState: true
- waitForAnimationToEnd:
timeout: 5000
# --- Auth ---
- assertVisible:
text: "E-Mail"
- tapOn:
text: "E-Mail"
- inputText: ${E2E_TEST_USER}@rebreak.internal
- tapOn:
text: "Passwort"
- inputText: ${E2E_TEST_PASSWORD}
- tapOn:
text: "Anmelden"
- waitForAnimationToEnd:
timeout: 10000
- assertVisible:
text: "ReBreak"
# Open dropdown → Profile
- tapOn:
point: "93%, 6%"
- waitForAnimationToEnd:
timeout: 2000
- assertVisible:
text: "Profil"
- tapOn:
text: "Profil"
- waitForAnimationToEnd:
timeout: 4000
- assertVisible:
text: "Profil"
# Scroll down to reach DemographicsAccordion (below StreakSection / UrgeStatsCard)
- scrollUntilVisible:
element:
text: "ANONYMER BEITRAG ZUR FORSCHUNG"
direction: DOWN
timeout: 6000
# Accordion header text is hardcoded — safe to use as selector.
- assertVisible:
text: "ANONYMER BEITRAG ZUR FORSCHUNG"
# Tap accordion header to expand
- tapOn:
text: "ANONYMER BEITRAG ZUR FORSCHUNG"
- waitForAnimationToEnd:
timeout: 1500
# After expansion: the progress bar area and field rows appear.
# "Geburtsjahr" label is hardcoded in DemographicsAccordion.
# FRAGILE: no testID on the row Pressable. Using text selector on label.
- scrollUntilVisible:
element:
text: "Geburtsjahr"
direction: DOWN
timeout: 3000
- assertVisible:
text: "Geburtsjahr"
# Tap Geburtsjahr row to open WheelPickerModal
- tapOn:
text: "Geburtsjahr"
- waitForAnimationToEnd:
timeout: 2000
# WheelPickerModal is open. Dismiss by tapping the backdrop or close button.
# WheelPickerModal has no testID on the backdrop. Tap outside the picker area.
- tapOn:
point: "50%, 10%"
- waitForAnimationToEnd:
timeout: 1000
# Tap Geschlecht (Gender) row — also hardcoded label text in DemographicsAccordion
- scrollUntilVisible:
element:
text: "Geschlecht"
direction: DOWN
timeout: 3000
- tapOn:
text: "Geschlecht"
- waitForAnimationToEnd:
timeout: 2000
# Dismiss gender picker
- tapOn:
point: "50%, 10%"
- waitForAnimationToEnd:
timeout: 1000
# Verify accordion is still expanded after dismissing pickers
- assertVisible:
text: "Geburtsjahr"

View File

@ -0,0 +1,113 @@
# profile/view-and-edit.yaml
# Tests: Login → open Header dropdown → tap "Profil" → ProfileScreen loads →
# tap edit (navigates to /profile/edit) → change nickname → tap Save.
# Pre-requisite: App installed. Test-user account exists on staging.
# Env-Vars: E2E_TEST_USER, E2E_TEST_PASSWORD
# Expected outcome: Save button (t('profile.edit_save') = "Speichern") tapped,
# screen returns to ProfileScreen without error.
#
# BLOCKER: Avatar Pressable in AppHeader has NO testID and NO accessibilityLabel.
# The dropdown open-tap uses a coordinate fallback (93%, 6%). This breaks if
# header layout changes. See TODO_TESTIDS.md — needs testID="header-avatar-btn".
appId: org.rebreak.app
---
- launchApp:
clearState: true
- waitForAnimationToEnd:
timeout: 5000
# --- Auth ---
- assertVisible:
text: "E-Mail"
- tapOn:
text: "E-Mail"
- inputText: ${E2E_TEST_USER}@rebreak.internal
- tapOn:
text: "Passwort"
- inputText: ${E2E_TEST_PASSWORD}
- tapOn:
text: "Anmelden"
- waitForAnimationToEnd:
timeout: 10000
# --- Home ---
- assertVisible:
text: "ReBreak"
# Open HeaderDropdownMenu via Avatar tap (top-right corner).
# FRAGILE — needs testID="header-avatar-btn" to become stable. See TODO_TESTIDS.md.
- tapOn:
point: "93%, 6%"
- waitForAnimationToEnd:
timeout: 2000
# Dropdown label = t('headerMenu.profile') = "Profil" (de.json)
- assertVisible:
text: "Profil"
- tapOn:
text: "Profil"
- waitForAnimationToEnd:
timeout: 4000
# ProfileScreen: AppHeader title is hardcoded "Profil" in ProfileScreen.
# "Posts" label in StatsBar (hardcoded, no i18n) confirms the screen rendered.
- assertVisible:
text: "Profil"
- assertVisible:
text: "Posts"
# Scroll down to make sure StatsBar area is visible before continuing
- scrollUntilVisible:
element:
text: "Posts"
direction: DOWN
timeout: 3000
# Navigate to edit screen.
# ProfileHeader has two Pressables: onEditAvatar + onEditNickname — both push /profile/edit.
# The camera icon area has no testID. We scroll back up first and tap the nickname area.
# FRAGILE — needs testID="profile-edit-nickname-btn" on the nickname Pressable.
# See TODO_TESTIDS.md.
- scrollUntilVisible:
element:
text: "Profil"
direction: UP
timeout: 3000
- tapOn:
point: "50%, 28%"
- waitForAnimationToEnd:
timeout: 3000
# Edit screen: title = t('profile.edit_title') — check SETUP.md for de.json value.
# Nickname TextInput has no testID. Tap and clear existing value, type new one.
# We use scrollUntilVisible to reach the nickname section.
- scrollUntilVisible:
element:
text: "NICKNAME"
direction: DOWN
timeout: 3000
# Nickname TextInput has no testID. Tap the placeholder text (t('auth.nicknamePlaceholder'))
# if field is empty, or tap directly below the "NICKNAME" label.
# clearText clears whatever is in the focused input.
- tapOn:
text: "NICKNAME"
- waitForAnimationToEnd:
timeout: 500
# The TextInput sits directly below the NICKNAME label — Maestro focuses the last
# tapped element. If placeholder is visible, tap it instead.
- clearText
- inputText: "TestNick"
# Save button = t('profile.edit_save') = "Speichern" (de.json). Only active when hasChanges.
- tapOn:
text: "Speichern"
- waitForAnimationToEnd:
timeout: 5000
# After save: router.back() → ProfileScreen. "Profil" title visible again.
- assertVisible:
text: "Profil"

View File

@ -0,0 +1,85 @@
# settings/dark-theme.yaml
# Tests: Login → Header dropdown → Settings → Theme picker → Dark mode selected.
# Pre-requisite: App installed. Test-user exists on staging.
# Env-Vars: E2E_TEST_USER, E2E_TEST_PASSWORD
# Expected outcome: After selecting "Dunkel", Settings screen reflects dark theme
# (background color changes — Maestro cannot assert CSS, but
# assertVisible on a dark-only element or the "Dunkel" value chip
# confirms the selection was persisted in local store).
#
# IMPORTANT: Theme is stored in MMKV (useThemeStore). clearState: true resets it
# to 'system' each run — so this flow always starts from system default.
#
# The theme menu is a @react-native-menu/menu MenuView (native iOS context menu).
# Maestro can trigger the anchor Pressable but may NOT be able to interact with
# the native UIMenu popover on iOS. If this step fails, it is a Maestro limitation
# with native context menus — add to SETUP.md known issues.
# Alternative: implement a test-only theme toggle button accessible via testID.
# See TODO_TESTIDS.md.
appId: org.rebreak.app
---
- launchApp:
clearState: true
- waitForAnimationToEnd:
timeout: 5000
# --- Auth ---
- assertVisible:
text: "E-Mail"
- tapOn:
text: "E-Mail"
- inputText: ${E2E_TEST_USER}@rebreak.internal
- tapOn:
text: "Passwort"
- inputText: ${E2E_TEST_PASSWORD}
- tapOn:
text: "Anmelden"
- waitForAnimationToEnd:
timeout: 10000
- assertVisible:
text: "ReBreak"
# Open Header dropdown → Settings
- tapOn:
point: "93%, 6%"
- waitForAnimationToEnd:
timeout: 2000
# t('headerMenu.settings') = "Einstellungen" (de.json)
- assertVisible:
text: "Einstellungen"
- tapOn:
text: "Einstellungen"
- waitForAnimationToEnd:
timeout: 3000
# Settings screen: AppHeader title = t('settings.title') = "Einstellungen"
- assertVisible:
text: "Einstellungen"
# Theme section: t('settings.theme') = "Erscheinungsbild" (de.json) — row label.
# The current value chip shows t('settings.theme_system') = "Systemstandard" initially.
- assertVisible:
text: "Erscheinungsbild"
# Tap the value chip (MenuView anchor Pressable) to open iOS UIMenu.
# WARNING: Native UIMenu interaction may not work in Maestro — see header note.
# We tap on the current value "Systemstandard" which is inside the anchor Pressable.
- tapOn:
text: "Systemstandard"
- waitForAnimationToEnd:
timeout: 1500
# iOS native UIMenu: items are "Systemstandard", "Hell", "Dunkel"
# t('settings.theme_dark') = "Dunkel" (de.json)
- tapOn:
text: "Dunkel"
- waitForAnimationToEnd:
timeout: 1500
# After selection: the value chip in the row should now show "Dunkel"
- assertVisible:
text: "Dunkel"

View File

@ -0,0 +1,94 @@
# urge/sos-flow.yaml
# Tests: Login → Header dropdown → SOS → Lyra chat screen loads → breathing exercise
# triggered → session ends → feedback rating drawer appears.
# Pre-requisite: App installed. Test-user exists on staging. Staging backend running.
# Groq API key configured (SOS streams via /api/coach/sos-stream).
# Env-Vars: E2E_TEST_USER, E2E_TEST_PASSWORD
# Expected outcome: Lyra chat input visible after SOS screen loads.
# Breathing drawer opens when "Atemübung" chip is tapped.
#
# TIMING NOTE: SOS screen boots RiveAvatar + streams first Lyra message via Groq.
# Timeout of 12s post-navigation is intentional — cold LLM response on staging.
#
# BLOCKER: Avatar button in AppHeader has no testID → coordinate fallback (93%, 6%).
# SOS-entry Pressable in HeaderDropdownMenu has no testID → text "SOS" selector works
# because "SOS" only appears inside the open dropdown.
appId: org.rebreak.app
---
- launchApp:
clearState: true
- waitForAnimationToEnd:
timeout: 5000
# --- Auth ---
- assertVisible:
text: "E-Mail"
- tapOn:
text: "E-Mail"
- inputText: ${E2E_TEST_USER}@rebreak.internal
- tapOn:
text: "Passwort"
- inputText: ${E2E_TEST_PASSWORD}
- tapOn:
text: "Anmelden"
- waitForAnimationToEnd:
timeout: 10000
- assertVisible:
text: "ReBreak"
# Open Header dropdown
- tapOn:
point: "93%, 6%"
- waitForAnimationToEnd:
timeout: 2000
# t('appHeader.sosLabel') = "SOS" (de.json) — red text, top of dropdown
- assertVisible:
text: "SOS"
- tapOn:
text: "SOS"
# SOS screen loads: RiveAvatar renders + Lyra streaming starts.
# Boot takes 6-12s on staging (Groq cold start + audio pre-cache).
- waitForAnimationToEnd:
timeout: 12000
# Chat input: placeholder = t('coach.placeholder') = "Was beschäftigt dich?" (de.json).
# This placeholder ONLY exists on the SOS/Urge screen.
- assertVisible:
text: "Was beschäftigt dich?"
# Type a message to trigger Lyra response + chip suggestions
- tapOn:
text: "Was beschäftigt dich?"
- inputText: "Ich habe gerade einen starken Drang."
- tapOn:
text: "Was beschäftigt dich?"
# After typing, a send button should appear (Ionicons send icon, no text/testID).
# Tap on the text input area then look for the send button via coordinates.
# FRAGILE — needs testID="sos-send-btn" on the send Pressable. See TODO_TESTIDS.md.
# Instead of sending (which triggers another streaming round-trip),
# we verify the chip row is visible if Lyra has already responded.
# If this is the first message, wait for Lyra's response + chip appearance.
- waitForAnimationToEnd:
timeout: 15000
# After Lyra responds, chipSet changes from 'start' to dynamic chips.
# "Atemübung" is a chip in CHIP_SETS.start (sosConstants.ts) — always shown first.
# Text is hardcoded in sosConstants.ts, not i18n.
# If visible: tap to open BreathingDrawer.
- assertVisible:
text: "Atemübung"
- tapOn:
text: "Atemübung"
- waitForAnimationToEnd:
timeout: 3000
# BreathingDrawer opens as a bottom sheet. Header text is hardcoded
# "Atemübung" in Breathing.tsx — safe to assert.
- assertVisible:
text: "Atemübung"

View File

@ -56,6 +56,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
plugins: [
"expo-router",
"expo-localization",
"expo-font",
"expo-web-browser",
[
"expo-build-properties",
{

View File

@ -5,7 +5,7 @@ import * as Notifications from 'expo-notifications';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../stores/auth';
import { useNotificationStore } from '../../stores/notifications';
import { colors } from '../../lib/theme';
import { useColors } from '../../lib/theme';
import { NativeTabs } from '../../components/NativeTabs';
import { protection } from '../../lib/protection';
import { preloadTabIcons, getTabIcon } from '../../lib/tabIcons';
@ -14,6 +14,7 @@ export default function AppLayout() {
const router = useRouter();
const { t } = useTranslation();
const { session, loading } = useAuthStore();
const colors = useColors();
const loadNotifications = useNotificationStore((s) => s.load);
const startRealtime = useNotificationStore((s) => s.startRealtime);
const stopRealtime = useNotificationStore((s) => s.stopRealtime);
@ -143,7 +144,7 @@ export default function AppLayout() {
if (loading || !session) {
return (
<View className="flex-1 bg-white items-center justify-center">
<View style={{ flex: 1, backgroundColor: colors.bg, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator color={colors.brandOrange} size="large" />
</View>
);

View File

@ -17,7 +17,7 @@ import { apiFetch } from '../../lib/api';
import { AppHeader } from '../../components/AppHeader';
import { RoomCard, type Room } from '../../components/chat/RoomCard';
import { CreateRoomSheet } from '../../components/chat/CreateRoomSheet';
import { colors } from '../../lib/theme';
import { useColors } from '../../lib/theme';
type DmConversation = {
partnerId: string;
@ -39,6 +39,8 @@ function formatTime(ts: string, justNowLabel: string): string {
function DmItem({ conv, onPress }: { conv: DmConversation; onPress: () => void }) {
const { t } = useTranslation();
const colors = useColors();
const styles = makeStyles(colors);
const hasUnread = conv.unreadCount > 0;
return (
@ -95,6 +97,8 @@ function DmItem({ conv, onPress }: { conv: DmConversation; onPress: () => void }
export default function ChatScreen() {
const { t } = useTranslation();
const router = useRouter();
const colors = useColors();
const styles = makeStyles(colors);
const [tab, setTab] = useState<'groups' | 'direct'>('groups');
const [createOpen, setCreateOpen] = useState(false);
@ -199,13 +203,13 @@ export default function ChatScreen() {
<RefreshControl
refreshing={refetchingRooms}
onRefresh={refetchRooms}
tintColor={colors.brandOrange}
tintColor="#007AFF"
/>
}
ListEmptyComponent={
loadingRooms ? (
<View style={styles.emptyBox}>
<ActivityIndicator color={colors.brandOrange} />
<ActivityIndicator color="#007AFF" />
</View>
) : (
<View style={styles.emptyBox}>
@ -225,13 +229,13 @@ export default function ChatScreen() {
<RefreshControl
refreshing={refetchingDms}
onRefresh={refetchDms}
tintColor={colors.brandOrange}
tintColor="#007AFF"
/>
}
ListEmptyComponent={
loadingDms ? (
<View style={styles.emptyBox}>
<ActivityIndicator color={colors.brandOrange} />
<ActivityIndicator color="#007AFF" />
</View>
) : (
<View style={styles.emptyBox}>
@ -257,15 +261,16 @@ export default function ChatScreen() {
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fafafa' },
function makeStyles(colors: ReturnType<typeof useColors>) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg },
headerSection: {
paddingHorizontal: 16,
paddingTop: 14,
paddingBottom: 10,
backgroundColor: '#fff',
backgroundColor: colors.bg,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#e5e5e5',
borderBottomColor: colors.border,
},
titleRow: {
flexDirection: 'row',
@ -275,7 +280,7 @@ const styles = StyleSheet.create({
title: {
fontSize: 22,
fontFamily: 'Nunito_800ExtraBold',
color: '#171717',
color: colors.text,
},
createBtn: {
width: 34,
@ -288,7 +293,7 @@ const styles = StyleSheet.create({
tabs: {
flexDirection: 'row',
marginTop: 12,
backgroundColor: '#f5f5f5',
backgroundColor: colors.surfaceElevated,
borderRadius: 10,
padding: 3,
},
@ -301,7 +306,7 @@ const styles = StyleSheet.create({
borderRadius: 8,
},
tabActive: {
backgroundColor: '#fff',
backgroundColor: colors.surface,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 2,
@ -310,7 +315,7 @@ const styles = StyleSheet.create({
tabText: {
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#737373',
color: colors.textMuted,
marginLeft: 5,
},
tabTextActive: {
@ -341,25 +346,24 @@ const styles = StyleSheet.create({
emptyText: {
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: '#a3a3a3',
color: colors.textMuted,
marginTop: 12,
},
// DM row styles
dmRow: {
width: '100%',
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 11,
backgroundColor: '#fff',
backgroundColor: colors.bg,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#f5f5f5',
borderBottomColor: colors.border,
},
dmAvatar: {
width: 42,
height: 42,
borderRadius: 21,
backgroundColor: '#e5e5e5',
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
@ -369,7 +373,7 @@ const styles = StyleSheet.create({
dmAvatarInitials: {
fontSize: 13,
fontFamily: 'Nunito_700Bold',
color: '#525252',
color: colors.textMuted,
},
dmInfo: { flex: 1, minWidth: 0 },
dmHeaderRow: {
@ -380,7 +384,7 @@ const styles = StyleSheet.create({
dmName: {
fontSize: 14,
fontFamily: 'Nunito_700Bold',
color: '#171717',
color: colors.text,
flexShrink: 1,
marginRight: 6,
},
@ -407,4 +411,5 @@ const styles = StyleSheet.create({
fontFamily: 'Nunito_700Bold',
color: '#fff',
},
});
});
}

View File

@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { View } from 'react-native';
import { useRouter, useFocusEffect } from 'expo-router';
import { useColors } from '../../lib/theme';
/**
* Placeholder-Screen für den Coach-Tab.
@ -11,6 +12,7 @@ import { useRouter, useFocusEffect } from 'expo-router';
*/
export default function CoachTabRedirect() {
const router = useRouter();
const colors = useColors();
useFocusEffect(
useCallback(() => {
@ -20,5 +22,5 @@ export default function CoachTabRedirect() {
}, [router]),
);
return <View style={{ flex: 1, backgroundColor: '#ffffff' }} />;
return <View style={{ flex: 1, backgroundColor: colors.bg }} />;
}

View File

@ -19,7 +19,7 @@ import { PostCardSkeleton } from '../../components/PostCardSkeleton';
import { PostCommentsSheet } from '../../components/PostCommentsSheet';
import { useCommunityStore, type CommunityCategory, type CommunityPost } from '../../stores/community';
import { useCommunityRealtime } from '../../hooks/useCommunityRealtime';
import { colors } from '../../lib/theme';
import { useColors } from '../../lib/theme';
type FilterChip = {
value: CommunityCategory;
@ -30,6 +30,7 @@ type FilterChip = {
export default function HomeScreen() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const colors = useColors();
// Granular selectors: subscribing to the whole store (incl. optimisticLikes)
// would re-render the screen — and thus the FlatList — on every like.
const activeCategory = useCommunityStore((s) => s.activeCategory);
@ -79,7 +80,7 @@ export default function HomeScreen() {
);
return (
<View className="flex-1 bg-neutral-50">
<View style={{ flex: 1, backgroundColor: colors.bg }}>
<AppHeader />
<FlatList
@ -139,23 +140,30 @@ export default function HomeScreen() {
<Pressable
key={f.value}
onPress={() => toggleFilter(f.value)}
className={`flex-row items-center gap-1.5 h-8 px-3 rounded-full border ${
active
? 'bg-rebreak-500 border-rebreak-500'
: 'bg-white border-neutral-200'
}`}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
style={({ pressed }) => ({
opacity: pressed ? 0.7 : 1,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
height: 32,
paddingHorizontal: 12,
borderRadius: 999,
borderWidth: 1,
backgroundColor: active ? colors.brandOrange : colors.surface,
borderColor: active ? colors.brandOrange : colors.border,
})}
>
<Ionicons
name={f.icon}
size={13}
color={active ? '#fff' : '#737373'}
color={active ? '#fff' : colors.textMuted}
/>
<Text
className={`text-xs ${
active ? 'text-white' : 'text-neutral-500'
}`}
style={{ fontFamily: 'Nunito_600SemiBold' }}
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: active ? '#fff' : colors.textMuted,
}}
>
{f.label}
</Text>
@ -178,9 +186,9 @@ export default function HomeScreen() {
}
ListEmptyComponent={
isLoading ? null : (
<View className="items-center py-16">
<Ionicons name="chatbubbles-outline" size={40} color="#d4d4d4" />
<Text className="text-sm text-neutral-400 mt-3 text-center" style={{ fontFamily: 'Nunito_400Regular' }}>
<View style={{ alignItems: 'center', paddingVertical: 64 }}>
<Ionicons name="chatbubbles-outline" size={40} color={colors.border} />
<Text style={{ fontSize: 14, color: colors.textMuted, marginTop: 12, textAlign: 'center', fontFamily: 'Nunito_400Regular' }}>
{t('community.no_posts')}
</Text>
</View>

View File

@ -20,10 +20,12 @@ import { SuccessAlert } from '../../components/SuccessAlert';
import { useMailStatus } from '../../hooks/useMailStatus';
import { useMailDisconnect } from '../../hooks/useMailDisconnect';
import { useUserPlan } from '../../hooks/useUserPlan';
import { useColors } from '../../lib/theme';
export default function MailScreen() {
const { t } = useTranslation();
const tabBarHeight = useBottomTabBarHeight();
const colors = useColors();
const { plan } = useUserPlan();
@ -72,7 +74,7 @@ export default function MailScreen() {
if (loading) {
return (
<View style={{ flex: 1, backgroundColor: '#fafafa' }}>
<View style={{ flex: 1, backgroundColor: colors.bg }}>
<AppHeader />
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator size="large" color="#007AFF" />
@ -82,7 +84,7 @@ export default function MailScreen() {
}
return (
<View style={{ flex: 1, backgroundColor: '#fafafa' }}>
<View style={{ flex: 1, backgroundColor: colors.bg }}>
<AppHeader />
<ScrollView
@ -118,7 +120,7 @@ export default function MailScreen() {
style={{
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: '#737373',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.8,
}}
@ -129,7 +131,7 @@ export default function MailScreen() {
style={{
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: '#a3a3a3',
color: colors.textMuted,
marginTop: 2,
}}
>
@ -147,7 +149,7 @@ export default function MailScreen() {
disabled={limitReached}
android_ripple={{ color: '#0066cc' }}
style={{
backgroundColor: limitReached ? '#e5e5e5' : '#007AFF',
backgroundColor: limitReached ? colors.surfaceElevated : '#007AFF',
borderRadius: 12,
opacity: limitReached ? 0.7 : 1,
shadowColor: '#007AFF',
@ -169,14 +171,14 @@ export default function MailScreen() {
<Ionicons
name="add"
size={18}
color={limitReached ? '#737373' : '#fff'}
color={limitReached ? colors.textMuted : '#fff'}
style={{ marginRight: 6 }}
/>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_700Bold',
color: limitReached ? '#737373' : '#fff',
color: limitReached ? colors.textMuted : '#fff',
}}
>
{t('mail.add_account')}

View File

@ -7,11 +7,12 @@ import { HeroShieldCheck } from '../../components/HeroShieldCheck';
import { useTranslation } from 'react-i18next';
import { EmptyState } from '../../components/EmptyState';
import { useNotificationStore, type AppNotification } from '../../stores/notifications';
import { colors } from '../../lib/theme';
import { useColors } from '../../lib/theme';
export default function NotificationsScreen() {
const router = useRouter();
const { t } = useTranslation();
const colors = useColors();
const items = useNotificationStore((s) => s.items);
const loaded = useNotificationStore((s) => s.loaded);
const load = useNotificationStore((s) => s.load);
@ -28,17 +29,16 @@ export default function NotificationsScreen() {
}, []);
return (
<SafeAreaView className="flex-1 bg-white" edges={['top']}>
<View className="flex-row items-center gap-3 px-5 pt-3 pb-3 border-b border-neutral-200">
<SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }} edges={['top']}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 20, paddingTop: 12, paddingBottom: 12, borderBottomWidth: 1, borderBottomColor: colors.border }}>
<Pressable
onPress={() => router.back()}
className="w-9 h-9 rounded-full bg-neutral-100 border border-neutral-200 items-center justify-center"
style={{ width: 36, height: 36, borderRadius: 18, backgroundColor: colors.surfaceElevated, borderWidth: 1, borderColor: colors.border, alignItems: 'center', justifyContent: 'center' }}
>
<Ionicons name="arrow-back" size={18} color="#737373" />
<Ionicons name="arrow-back" size={18} color={colors.textMuted} />
</Pressable>
<Text
className="text-neutral-900 text-lg flex-1"
style={{ fontFamily: 'Nunito_700Bold' }}
style={{ color: colors.text, fontSize: 18, flex: 1, fontFamily: 'Nunito_700Bold' }}
>
{t('notifications.title')}
</Text>
@ -59,7 +59,7 @@ export default function NotificationsScreen() {
<RefreshControl
refreshing={!loaded}
onRefresh={load}
tintColor={colors.brandOrange}
tintColor="#007AFF"
/>
}
renderItem={({ item }) => (
@ -88,6 +88,7 @@ function NotificationRow({
onPress: () => void;
onDelete: () => void;
}) {
const colors = useColors();
const isUnread = !notif.readAt;
return (
<Pressable
@ -103,8 +104,8 @@ function NotificationRow({
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#f5f5f5',
backgroundColor: isUnread ? '#fff7ed' : '#fff',
borderBottomColor: colors.border,
backgroundColor: isUnread ? colors.surface : colors.bg,
}}
>
{/* Pure-Icon — KEIN bg-Circle (User-Wunsch: kein extra Rand). */}
@ -117,7 +118,7 @@ function NotificationRow({
</View>
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
<Text
style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}
style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: colors.text }}
numberOfLines={1}
>
{notif.actorName}
@ -127,7 +128,7 @@ function NotificationRow({
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#525252',
color: colors.textMuted,
marginTop: 2,
}}
numberOfLines={2}

View File

@ -3,6 +3,7 @@ import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import * as Notifications from 'expo-notifications';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { KeyboardProvider } from 'react-native-keyboard-controller';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { ActionSheetProvider } from '@expo/react-native-action-sheet';
@ -16,8 +17,10 @@ import {
} from '@expo-google-fonts/nunito';
import { useAuthStore } from '../stores/auth';
import { useThemeStore } from '../stores/theme';
import { useColors } from '../lib/theme';
import { useLanguageStore } from '../stores/language';
import { BrandSplash } from '../components/BrandSplash';
import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet';
import '../lib/i18n'; // i18next-Init via Side-Effect
import '../global.css';
@ -44,7 +47,9 @@ const queryClient = new QueryClient({
function RootLayoutInner() {
const { loading, init } = useAuthStore();
const initTheme = useThemeStore((s) => s.init);
const colorScheme = useThemeStore((s) => s.colorScheme);
const initLanguage = useLanguageStore((s) => s.init);
const colors = useColors();
const [fontsLoaded] = useFonts({
Nunito_400Regular,
Nunito_600SemiBold,
@ -70,12 +75,13 @@ function RootLayoutInner() {
return (
<>
<StatusBar style="dark" />
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
<DeviceLimitReachedSheet />
<Stack
screenOptions={{
headerShown: false,
animation: 'slide_from_right',
contentStyle: { backgroundColor: '#ffffff' },
contentStyle: { backgroundColor: colors.bg },
}}
>
<Stack.Screen name="index" />
@ -153,6 +159,7 @@ function RootLayoutInner() {
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<QueryClientProvider client={queryClient}>
<ActionSheetProvider>
<SafeAreaProvider>
@ -160,6 +167,7 @@ export default function RootLayout() {
</SafeAreaProvider>
</ActionSheetProvider>
</QueryClientProvider>
</KeyboardProvider>
</GestureHandlerRootView>
);
}

View File

@ -15,10 +15,11 @@ import { useEffect } from 'react';
import { View, ActivityIndicator } from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { supabase } from '../../lib/supabase';
import { colors } from '../../lib/theme';
import { useColors } from '../../lib/theme';
export default function AuthCallback() {
const router = useRouter();
const colors = useColors();
const params = useLocalSearchParams<{ access_token?: string; refresh_token?: string }>();
useEffect(() => {
@ -50,7 +51,7 @@ export default function AuthCallback() {
}, []);
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#ffffff' }}>
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg }}>
<ActivityIndicator size="large" color={colors.brandOrange} />
</View>
);

View File

@ -3,10 +3,11 @@ import { View, Text, ScrollView, Pressable } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../lib/theme';
import { useColors } from '../lib/theme';
export default function DebugScreen() {
const router = useRouter();
const colors = useColors();
useEffect(() => {
if (!__DEV__) {
@ -15,11 +16,11 @@ export default function DebugScreen() {
}, [router]);
if (!__DEV__) {
return <View style={{ flex: 1, backgroundColor: '#ffffff' }} />;
return <View style={{ flex: 1, backgroundColor: colors.bg }} />;
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: '#ffffff' }} edges={['top']}>
<SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }} edges={['top']}>
<View
style={{
paddingHorizontal: 12,
@ -48,7 +49,7 @@ export default function DebugScreen() {
<Ionicons name="chevron-back" size={26} color={colors.text} />
</View>
</Pressable>
<Text style={{ fontSize: 20, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>
<Text style={{ fontSize: 20, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
Debug
</Text>
</View>
@ -119,10 +120,11 @@ function DebugStub({
subtitle: string;
icon: React.ComponentProps<typeof Ionicons>['name'];
}) {
const colors = useColors();
return (
<View
style={{
backgroundColor: '#fafafa',
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
@ -139,19 +141,19 @@ function DebugStub({
width: 36,
height: 36,
borderRadius: 11,
backgroundColor: '#e5e7eb',
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={icon} size={18} color="#525252" />
<Ionicons name={icon} size={18} color={colors.textMuted} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>{title}</Text>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold' }}>{title}</Text>
<Text
style={{
fontSize: 12,
color: '#737373',
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 3,
lineHeight: 17,

View File

@ -0,0 +1,382 @@
import {
ActivityIndicator,
Alert,
Platform,
Pressable,
ScrollView,
Text,
View,
} from 'react-native';
import { useEffect } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useColors } from '../lib/theme';
import { useDevicesStore, type UserDevice } from '../stores/devices';
import { AppHeader } from '../components/AppHeader';
function platformIcon(
platform: string
): React.ComponentProps<typeof Ionicons>['name'] {
if (platform === 'ios') return 'logo-apple';
if (platform === 'android') return 'logo-android';
return 'phone-portrait-outline';
}
function formatLastSeen(iso: string, t: (k: string, o?: any) => string): string {
const ms = Date.now() - new Date(iso).getTime();
const min = Math.floor(ms / 60_000);
if (min < 1) return t('settings.devices_just_now');
if (min < 60) return t('settings.devices_mins_ago', { count: min });
const hr = Math.floor(min / 60);
if (hr < 24) return t('settings.devices_hours_ago', { count: hr });
const day = Math.floor(hr / 24);
if (day < 30) return t('settings.devices_days_ago', { count: day });
return new Date(iso).toLocaleDateString(Platform.OS === 'ios' ? undefined : 'de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
function formatSince(iso: string): string {
return new Date(iso).toLocaleDateString(Platform.OS === 'ios' ? undefined : 'de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
function DeviceRow({
device,
onRemove,
}: {
device: UserDevice;
onRemove: (id: string) => void;
}) {
const { t } = useTranslation();
const colors = useColors();
function confirmRemove() {
Alert.alert(
t('settings.devices_remove_title'),
t('settings.devices_remove_desc'),
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('settings.devices_remove_confirm'),
style: 'destructive',
onPress: () => onRemove(device.id),
},
]
);
}
return (
<View
style={{
flexDirection: 'row',
alignItems: 'flex-start',
gap: 12,
paddingHorizontal: 14,
paddingVertical: 14,
}}
>
<View
style={{
width: 40,
height: 40,
borderRadius: 12,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons
name={platformIcon(device.platform)}
size={20}
color={colors.text}
/>
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text
numberOfLines={1}
style={{
fontSize: 15,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
flexShrink: 1,
}}
>
{device.name ?? device.model ?? device.platform}
</Text>
{device.isCurrent ? (
<View
style={{
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 6,
backgroundColor: 'rgba(249,115,22,0.12)',
}}
>
<Text
style={{
fontSize: 10,
color: colors.brandOrange,
fontFamily: 'Nunito_600SemiBold',
}}
>
{t('settings.devices_this_device')}
</Text>
</View>
) : null}
</View>
{device.model &&
device.name &&
!device.name.includes(device.model) ? (
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 1,
}}
>
{device.model}
</Text>
) : null}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginTop: 4,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
<Ionicons name="time-outline" size={11} color={colors.textMuted} />
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{formatLastSeen(device.lastSeenAt, t)}
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
<Ionicons name="link-outline" size={11} color={colors.textMuted} />
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{t('settings.devices_since')} {formatSince(device.createdAt)}
</Text>
</View>
</View>
</View>
{!device.isCurrent ? (
<Pressable
onPress={confirmRemove}
hitSlop={8}
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
>
<Ionicons name="trash-outline" size={18} color={colors.error} />
</Pressable>
) : null}
</View>
);
}
export default function DevicesScreen() {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const colors = useColors();
const { devices, maxDevices, plan, loading, loadDevices, removeDevice } =
useDevicesStore();
useEffect(() => {
loadDevices();
}, []);
const atLimit = devices.length >= maxDevices;
const fillRatio = Math.min(1, devices.length / Math.max(1, maxDevices));
return (
<View style={{ flex: 1, backgroundColor: colors.bg }}>
<AppHeader showBack title={t('settings.devices_page_title')} />
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: insets.bottom + 40,
}}
showsVerticalScrollIndicator={false}
>
{/* Slot counter card */}
<View
style={{
backgroundColor: colors.surface,
borderRadius: 14,
padding: 16,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.04,
shadowRadius: 3,
elevation: 1,
}}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
}}
>
<Text
style={{
fontSize: 14,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
{t('settings.devices_slots')}
</Text>
<View
style={{
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 20,
borderWidth: 1,
borderColor: atLimit
? 'rgba(239,68,68,0.3)'
: 'rgba(0,0,0,0.08)',
backgroundColor: atLimit
? 'rgba(239,68,68,0.08)'
: 'transparent',
}}
>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: atLimit ? colors.error : colors.textMuted,
}}
>
{devices.length} / {maxDevices}
</Text>
</View>
</View>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginBottom: 10,
}}
>
{t('settings.devices_slots_desc', { plan: plan.toUpperCase() })}
</Text>
<View
style={{
height: 5,
borderRadius: 4,
backgroundColor: 'rgba(0,0,0,0.06)',
overflow: 'hidden',
}}
>
<View
style={{
height: 5,
borderRadius: 4,
width: `${fillRatio * 100}%`,
backgroundColor: atLimit
? colors.error
: fillRatio >= 0.8
? '#f59e0b'
: colors.brandOrange,
}}
/>
</View>
</View>
{/* Device list card */}
<View
style={{
backgroundColor: colors.surface,
borderRadius: 14,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.04,
shadowRadius: 3,
elevation: 1,
}}
>
{loading ? (
<View
style={{
paddingVertical: 32,
alignItems: 'center',
}}
>
<ActivityIndicator color={colors.brandOrange} />
</View>
) : devices.length === 0 ? (
<View style={{ paddingVertical: 32, alignItems: 'center' }}>
<Text
style={{
fontSize: 13,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{t('settings.devices_empty')}
</Text>
</View>
) : (
devices.map((device, i) => (
<View
key={device.id}
style={{
borderBottomWidth: i < devices.length - 1 ? 1 : 0,
borderBottomColor: colors.border,
}}
>
<DeviceRow device={device} onRemove={removeDevice} />
</View>
))
)}
</View>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 12,
marginHorizontal: 4,
lineHeight: 16,
}}
>
{t('settings.devices_hint')}
</Text>
</ScrollView>
</View>
);
}

View File

@ -18,9 +18,10 @@ import { useTranslation } from 'react-i18next';
import { apiFetch } from '../lib/api';
import { supabase } from '../lib/supabase';
import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble';
import { resolveAvatar } from '../lib/resolveAvatar';
import { ChatInput, type SendPayload } from '../components/chat/ChatInput';
import { useDmRealtime } from '../hooks/useChatRealtime';
import { colors } from '../lib/theme';
import { useColors } from '../lib/theme';
type DmHistoryResponse = {
partner: {
@ -52,6 +53,8 @@ export default function DmScreen() {
const { t } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const colors = useColors();
const styles = makeStyles(colors);
const queryClient = useQueryClient();
const flatRef = useRef<FlatList>(null);
const [myUserId, setMyUserId] = useState<string | undefined>(undefined);
@ -234,12 +237,12 @@ export default function DmScreen() {
{/* Header */}
<View style={styles.header}>
<Pressable style={styles.backBtn} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color="#0a0a0a" />
<Ionicons name="chevron-back" size={22} color={colors.text} />
</Pressable>
<View style={styles.headerCenter}>
<View style={styles.headerAvatar}>
{partner?.avatar ? (
<Image source={{ uri: partner.avatar }} style={styles.headerAvatarImg} />
<Image source={{ uri: resolveAvatar(partner.avatar, partner.nickname ?? '') }} style={styles.headerAvatarImg} />
) : (
<Text style={styles.headerAvatarInitials}>
{(partner?.nickname ?? '?').slice(0, 2).toUpperCase()}
@ -260,7 +263,7 @@ export default function DmScreen() {
>
{isLoading && messages.length === 0 ? (
<View style={styles.loadingBox}>
<ActivityIndicator color={colors.brandOrange} />
<ActivityIndicator color="#007AFF" />
</View>
) : messages.length === 0 ? (
<View style={styles.loadingBox}>
@ -302,23 +305,24 @@ export default function DmScreen() {
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fafafa' },
function makeStyles(colors: ReturnType<typeof useColors>) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: '#fff',
backgroundColor: colors.bg,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#e5e5e5',
borderBottomColor: colors.border,
},
backBtn: {
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: '#f5f5f5',
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
},
@ -333,7 +337,7 @@ const styles = StyleSheet.create({
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#e5e5e5',
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
@ -343,12 +347,12 @@ const styles = StyleSheet.create({
headerAvatarInitials: {
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: '#737373',
color: colors.textMuted,
},
headerName: {
fontSize: 15,
fontFamily: 'Nunito_700Bold',
color: '#171717',
color: colors.text,
flexShrink: 1,
},
loadingBox: {
@ -359,7 +363,8 @@ const styles = StyleSheet.create({
emptyText: {
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: '#a3a3a3',
color: colors.textMuted,
marginTop: 12,
},
});
});
}

View File

@ -13,7 +13,7 @@ import {
TetrisGame,
} from '../components/urge/UrgeGames';
import { GameCard } from '../components/games/GameCard';
import { colors } from '../lib/theme';
import { useColors } from '../lib/theme';
import { apiFetch } from '../lib/api';
type GameStat = { avgStars: number; count: number };
@ -31,6 +31,7 @@ type LastScore = { game: GameType; score: number } | null;
export default function GamesScreen() {
const router = useRouter();
const { t } = useTranslation();
const colors = useColors();
const [active, setActive] = useState<GameType | null>(null);
const [lastScore, setLastScore] = useState<LastScore>(null);
const [gameStats, setGameStats] = useState<GameStats>(EMPTY_STATS);
@ -70,7 +71,7 @@ export default function GamesScreen() {
if (active) {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: '#ffffff' }} edges={['top']}>
<SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }} edges={['top']}>
<View
style={{
paddingHorizontal: 12,
@ -79,7 +80,7 @@ export default function GamesScreen() {
alignItems: 'center',
justifyContent: 'space-between',
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.06)',
borderBottomColor: colors.border,
}}
>
<Pressable
@ -102,9 +103,9 @@ export default function GamesScreen() {
</Text>
</View>
</Pressable>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
{t(GAME_META.find((g) => g.id === active)!.titleKey)}
</Text>
{/* Title bewusst entfernt der Game-Picker hat das Spiel schon ausgewählt,
Wiederholung im Header lenkt nur ab. Spacer balanciert den Back-Button. */}
<View style={{ flex: 1 }} />
<View style={{ width: 60 }} />
</View>
@ -127,7 +128,7 @@ export default function GamesScreen() {
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: '#ffffff' }} edges={['top']}>
<SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }} edges={['top']}>
<View
style={{
paddingHorizontal: 12,
@ -137,7 +138,7 @@ export default function GamesScreen() {
alignItems: 'center',
gap: 8,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.06)',
borderBottomColor: colors.border,
}}
>
<Pressable
@ -156,7 +157,7 @@ export default function GamesScreen() {
<Ionicons name="chevron-back" size={26} color={colors.text} />
</View>
</Pressable>
<Text style={{ fontSize: 20, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>
<Text style={{ fontSize: 20, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
{t('games.title')}
</Text>
</View>
@ -169,7 +170,7 @@ export default function GamesScreen() {
<Text
style={{
fontSize: 13,
color: '#737373',
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
lineHeight: 19,
marginBottom: 18,
@ -232,7 +233,7 @@ export default function GamesScreen() {
style={{
textAlign: 'center',
fontSize: 11,
color: '#a3a3a3',
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 24,
opacity: 0.7,

View File

@ -23,23 +23,17 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { Audio } from 'expo-av';
import * as FileSystem from 'expo-file-system';
// TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14
import * as FileSystem from 'expo-file-system/legacy';
import Constants from 'expo-constants';
import { useTranslation } from 'react-i18next';
import { RiveAvatar, type Emotion } from '../components/RiveAvatar';
import { useCoachStore, type Message } from '../stores/coach';
import { apiFetch } from '../lib/api';
import { supabase } from '../lib/supabase';
import { colors } from '../lib/theme';
const EMPATHY_RE = /schwer|rückfall|traurig|schlimm|hoffnungslos|hilf|verloren|scham|schuld|verzweifelt/i;
const HAPPY_RE = /toll|super|geschafft|stark|glückwunsch|stolz|fantastisch|weiter so|prima|gut gemacht/i;
function detectEmotion(text: string): Emotion {
if (HAPPY_RE.test(text)) return 'happy';
if (EMPATHY_RE.test(text)) return 'empathy';
return 'idle';
}
import { useColors } from '../lib/theme';
import { useThemeStore } from '../stores/theme';
import { detectEmotion } from '../lib/lyraResponse';
function formatDuration(s: number): string {
const m = Math.floor(s / 60);
@ -55,6 +49,7 @@ function formatTimestamp(date: Date): string {
// Standard-Spinner — kein zweiter Rive-Avatar (der ist bereits im topBar oben).
function LoadingPulse() {
const colors = useColors();
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<ActivityIndicator size="large" color={colors.textMuted} />
@ -65,6 +60,7 @@ function LoadingPulse() {
// ── Thinking dots ─────────────────────────────────────────────────────────────
function ThinkingDots() {
const colors = useColors();
const anim = useRef([new Animated.Value(0), new Animated.Value(0), new Animated.Value(0)]).current;
useEffect(() => {
@ -88,7 +84,7 @@ function ThinkingDots() {
key={i}
style={[
styles.thinkingDot,
{ transform: [{ translateY: a.interpolate({ inputRange: [0, 1], outputRange: [0, -5] }) }] },
{ backgroundColor: colors.border, transform: [{ translateY: a.interpolate({ inputRange: [0, 1], outputRange: [0, -5] }) }] },
]}
/>
))}
@ -137,13 +133,14 @@ function MessageRow({
item: MessageWithMeta;
t: (key: string) => string;
}) {
const colors = useColors();
const isUser = item.role === 'user';
return (
<View style={[styles.msgRow, isUser ? styles.msgRowUser : styles.msgRowAssistant]}>
<View style={[styles.bubbleCol, isUser ? styles.bubbleColUser : styles.bubbleColAssistant]}>
<View style={[styles.bubble, isUser ? styles.bubbleUser : styles.bubbleAssistant]}>
<Text style={[styles.bubbleText, isUser ? styles.bubbleTextUser : styles.bubbleTextAssistant]}>
<View style={[styles.bubble, isUser ? styles.bubbleUser : [styles.bubbleAssistant, { backgroundColor: colors.surfaceElevated }]]}>
<Text style={[styles.bubbleText, isUser ? styles.bubbleTextUser : [styles.bubbleTextAssistant, { color: colors.text }]]}>
{item.content}
</Text>
</View>
@ -151,11 +148,11 @@ function MessageRow({
<View style={[styles.metaRow, isUser ? styles.metaRowUser : styles.metaRowAssistant]}>
{item.feedbackSaved && (
<>
<Ionicons name="checkmark-circle" size={11} color="#16a34a" />
<Text style={styles.feedbackText}>{t('coach.feedback_saved')}</Text>
<Ionicons name="checkmark-circle" size={11} color={colors.success} />
<Text style={[styles.feedbackText, { color: colors.success }]}>{t('coach.feedback_saved')}</Text>
</>
)}
<Text style={styles.timestampText}>{formatTimestamp(item.timestamp)}</Text>
<Text style={[styles.timestampText, { color: colors.textMuted }]}>{formatTimestamp(item.timestamp)}</Text>
</View>
</View>
</View>
@ -169,6 +166,8 @@ export default function CoachScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const flatRef = useRef<FlatList>(null);
const colors = useColors();
const colorScheme = useThemeStore((s) => s.colorScheme);
// Reaktive Slices — nur was UI-relevant ist (Re-Render bei diesen).
const messages = useCoachStore((s) => s.messages);
@ -357,14 +356,17 @@ export default function CoachScreen() {
try {
const apiBase = Constants.expoConfig?.extra?.apiUrl as string;
const session = (await supabase.auth.getSession()).data.session;
const ttsRes = await fetch(`${apiBase}/api/coach/speak-openai`, {
const speakUrl = `${apiBase}/api/coach/speak`;
console.log('[tts] POST', speakUrl, 'text-len:', res.message.length);
const ttsRes = await fetch(speakUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}),
},
body: JSON.stringify({ text: res.message, locale: i18n.language }),
body: JSON.stringify({ text: res.message, mode: 'chat' }),
});
console.log('[tts] response status:', ttsRes.status, 'content-type:', ttsRes.headers.get('content-type'));
if (ttsRes.ok) {
// Backend streamt audio/mpeg direkt — als ArrayBuffer holen, base64-encoden, in tmp-File schreiben.
const buffer = await ttsRes.arrayBuffer();
@ -538,17 +540,17 @@ export default function CoachScreen() {
);
return (
<SafeAreaView style={styles.container} edges={['top']}>
<SafeAreaView style={[styles.container, { backgroundColor: colors.bg }]} edges={['top']}>
{/* Backdrop hinter topBar — verhindert dass Chat-Texte hinter dem Avatar */}
{/* sichtbar werden ("kollidieren mit Lyra schriftzug"). */}
<View
style={[styles.topBarBackdrop, { height: insets.top + 170 }]}
style={[styles.topBarBackdrop, { height: insets.top + 170, backgroundColor: colors.bg }]}
pointerEvents="none"
/>
{/* Floating header — no bar, avatar + 2 icon buttons hover over chat */}
<View style={[styles.topBar, { top: insets.top + 6 }]}>
<Pressable style={styles.backBtn} onPress={() => router.replace('/(app)' as never)} hitSlop={12}>
<Pressable style={[styles.backBtn, { backgroundColor: colorScheme === 'dark' ? 'rgba(44,44,46,0.92)' : 'rgba(255,255,255,0.92)' }]} onPress={() => router.replace('/(app)' as never)} hitSlop={12}>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</Pressable>
@ -558,7 +560,7 @@ export default function CoachScreen() {
</View>
<View style={styles.avatarMeta}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text style={styles.avatarName}>{t('coach.lyra')}</Text>
<Text style={[styles.avatarName, { color: colors.text }]}>{t('coach.lyra')}</Text>
<View
style={{
paddingHorizontal: 6,
@ -585,7 +587,7 @@ export default function CoachScreen() {
<View style={styles.speakingRow}>
<VoiceBars count={5} baseColor={colors.brandOrange} />
<Text style={[styles.speakingLabel, { color: colors.brandOrange }]}>{t('coach.speaking')}</Text>
<Pressable style={styles.stopBtn} onPress={stopSpeaking} hitSlop={6}>
<Pressable style={[styles.stopBtn, { backgroundColor: colors.surfaceElevated }]} onPress={stopSpeaking} hitSlop={6}>
<Ionicons name="square" size={10} color={colors.brandOrange} />
</Pressable>
</View>
@ -593,7 +595,7 @@ export default function CoachScreen() {
</View>
</View>
<Pressable style={styles.newChatBtn} onPress={handleNewChat} hitSlop={12}>
<Pressable style={[styles.newChatBtn, { backgroundColor: colorScheme === 'dark' ? 'rgba(44,44,46,0.92)' : 'rgba(255,255,255,0.92)' }]} onPress={handleNewChat} hitSlop={12}>
<Ionicons name="refresh-outline" size={22} color={colors.textMuted} />
</Pressable>
</View>
@ -625,7 +627,7 @@ export default function CoachScreen() {
ListFooterComponent={
thinking ? (
<View style={styles.msgRowAssistant}>
<View style={styles.bubbleAssistant}>
<View style={[styles.bubbleAssistant, { backgroundColor: colors.surfaceElevated }]}>
<ThinkingDots />
</View>
</View>
@ -643,7 +645,7 @@ export default function CoachScreen() {
)}
{/* Input bar */}
<View style={[styles.inputBar, { paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom) }]}>
<View style={[styles.inputBar, { paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom), backgroundColor: colors.bg, borderTopColor: colors.border }]}>
{isRecording ? (
<View style={styles.recordingContainer}>
<Pressable style={styles.cancelBtn} onPress={cancelRecording}>
@ -658,13 +660,13 @@ export default function CoachScreen() {
) : isTranscribing ? (
<View style={styles.transcribingRow}>
<Ionicons name="sync" size={16} color={colors.textMuted} />
<Text style={styles.transcribingText}>{t('coach.transcribing')}</Text>
<Text style={[styles.transcribingText, { color: colors.textMuted }]}>{t('coach.transcribing')}</Text>
</View>
) : (
<TextInput
style={styles.textInput}
style={[styles.textInput, { backgroundColor: colors.surfaceElevated, color: colors.text }]}
placeholder={t('coach.placeholder')}
placeholderTextColor="#a3a3a3"
placeholderTextColor={colors.textMuted}
value={input}
onChangeText={handleInputChange}
multiline
@ -676,7 +678,7 @@ export default function CoachScreen() {
{!isTranscribing && (
<Pressable
style={[styles.micBtn, isRecording && styles.micBtnActive, thinking && styles.micBtnDisabled]}
style={[styles.micBtn, { backgroundColor: colors.surfaceElevated }, isRecording && styles.micBtnActive, thinking && styles.micBtnDisabled]}
onPressIn={onMicDown}
onPressOut={onMicUp}
disabled={thinking}
@ -763,7 +765,7 @@ const styles = StyleSheet.create({
statusLabel: {
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
color: '#737373',
},
speakingRow: {
flexDirection: 'row',

View File

@ -3,7 +3,7 @@ import { View, Text, ScrollView, Pressable, Image } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../../lib/theme';
import { useColors } from '../../lib/theme';
import { resolveAvatar } from '../../lib/resolveAvatar';
import type { Plan } from '../../hooks/useUserPlan';
@ -52,13 +52,14 @@ type StatProps = {
};
function ForeignStat({ value, label }: StatProps) {
const colors = useColors();
return (
<View
style={{
flex: 1,
backgroundColor: '#ffffff',
backgroundColor: colors.bg,
borderWidth: 1,
borderColor: '#e5e5e5',
borderColor: colors.border,
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
@ -85,6 +86,7 @@ function ForeignStat({ value, label }: StatProps) {
export default function ForeignProfileScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const colors = useColors();
const { userId } = useLocalSearchParams<{ userId: string }>();
const [imageFailed, setImageFailed] = useState(false);
const [isFollowing, setIsFollowing] = useState(DUMMY_FOREIGN.isFollowing);
@ -99,13 +101,13 @@ export default function ForeignProfileScreen() {
const planStyle = planColors[profile.plan];
return (
<View style={{ flex: 1, backgroundColor: '#ffffff' }}>
<View style={{ flex: 1, backgroundColor: colors.bg }}>
<View
style={{
paddingTop: insets.top,
backgroundColor: '#ffffff',
backgroundColor: colors.bg,
borderBottomWidth: 1,
borderBottomColor: '#e5e5e5',
borderBottomColor: colors.border,
}}
>
<View
@ -147,7 +149,7 @@ export default function ForeignProfileScreen() {
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
backgroundColor: showImage ? '#fafafa' : colors.brandOrange,
backgroundColor: showImage ? colors.surface : colors.brandOrange,
}}
>
{showImage ? (
@ -215,9 +217,9 @@ export default function ForeignProfileScreen() {
<View style={{
paddingVertical: 11,
borderRadius: 12,
backgroundColor: isFollowing ? '#f5f5f5' : colors.brandOrange,
backgroundColor: isFollowing ? colors.surfaceElevated : colors.brandOrange,
borderWidth: 1,
borderColor: isFollowing ? '#e5e5e5' : colors.brandOrange,
borderColor: isFollowing ? colors.border : colors.brandOrange,
alignItems: 'center',
}}>
<Text
@ -244,9 +246,9 @@ export default function ForeignProfileScreen() {
<View style={{
paddingVertical: 11,
borderRadius: 12,
backgroundColor: '#ffffff',
backgroundColor: colors.bg,
borderWidth: 1,
borderColor: '#e5e5e5',
borderColor: colors.border,
alignItems: 'center',
}}>
<Text
@ -292,9 +294,9 @@ export default function ForeignProfileScreen() {
</Text>
<View
style={{
backgroundColor: '#ffffff',
backgroundColor: colors.bg,
borderWidth: 1,
borderColor: '#e5e5e5',
borderColor: colors.border,
borderRadius: 14,
padding: 16,
alignItems: 'center',

View File

@ -0,0 +1,320 @@
import { useState } from 'react';
import {
View,
Text,
TextInput,
Pressable,
ScrollView,
Image,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Alert,
} from 'react-native';
import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
// TODO(sdk54): migrate to new expo-file-system class-based API — see Task #14
import * as FileSystem from 'expo-file-system/legacy';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
import { HERO_AVATARS, getAvatarUrl } from '../../lib/avatars';
import { resolveAvatar } from '../../lib/resolveAvatar';
import { apiFetch } from '../../lib/api';
import { useMe } from '../../hooks/useMe';
export default function ProfileEditScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const colors = useColors();
const { me, reload } = useMe();
const INPUT_STYLE = {
fontSize: 16,
lineHeight: 22,
paddingVertical: 14,
paddingHorizontal: 16,
color: colors.text,
fontFamily: 'Nunito_400Regular',
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
} as const;
const [nickname, setNickname] = useState(me?.nickname ?? '');
const [avatarId, setAvatarId] = useState<string | null>(me?.avatar ?? null);
const [photoUri, setPhotoUri] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const displayAvatar = photoUri ?? avatarId;
async function pickPhoto() {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert(
t('profile.edit_photo_perm_title'),
t('profile.edit_photo_perm_desc'),
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.7,
});
if (result.canceled || !result.assets[0]) return;
const uri = result.assets[0].uri;
setPhotoUri(uri);
setAvatarId(null);
}
async function save() {
if (!nickname.trim()) return;
setSaving(true);
try {
let finalAvatar: string | null = avatarId;
if (photoUri) {
setUploading(true);
const base64 = await FileSystem.readAsStringAsync(photoUri, {
encoding: FileSystem.EncodingType.Base64,
});
const ext = photoUri.toLowerCase().endsWith('.png') ? 'png' : 'jpeg';
const dataUrl = `data:image/${ext};base64,${base64}`;
const res = await apiFetch<{ url: string }>('/api/avatar/upload', {
method: 'POST',
body: { dataUrl },
});
finalAvatar = res.url;
setUploading(false);
}
await apiFetch('/api/auth/me', {
method: 'PATCH',
body: {
nickname: nickname.trim(),
...(finalAvatar !== me?.avatar ? { avatar: finalAvatar } : {}),
},
});
reload();
router.back();
} catch (err: any) {
setUploading(false);
console.error('[profile/edit] save failed:', err?.message ?? err);
Alert.alert(t('common.error'), err?.message ?? t('common.unknown_error'));
} finally {
setSaving(false);
}
}
const resolvedPreview = photoUri
? photoUri
: resolveAvatar(avatarId, nickname || (me?.nickname ?? ''));
const hasChanges =
nickname.trim() !== (me?.nickname ?? '') ||
photoUri !== null ||
avatarId !== me?.avatar;
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: colors.bg }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingTop: insets.top + 8,
paddingBottom: 12,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: colors.border,
backgroundColor: colors.bg,
}}
>
<Pressable
onPress={() => router.back()}
hitSlop={10}
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, marginRight: 12 })}
>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</Pressable>
<Text style={{ flex: 1, fontSize: 17, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
{t('profile.edit_title')}
</Text>
<Pressable
onPress={save}
disabled={saving || !hasChanges || !nickname.trim()}
style={({ pressed }) => ({
opacity: pressed || saving || !hasChanges || !nickname.trim() ? 0.4 : 1,
})}
>
{saving ? (
<ActivityIndicator size="small" color={colors.brandOrange} />
) : (
<Text
style={{
fontSize: 16,
color: colors.brandOrange,
fontFamily: 'Nunito_700Bold',
}}
>
{t('profile.edit_save')}
</Text>
)}
</Pressable>
</View>
<ScrollView
contentContainerStyle={{ paddingBottom: insets.bottom + 32 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* Avatar preview + pick-photo button */}
<View style={{ alignItems: 'center', paddingTop: 32, paddingBottom: 24 }}>
<View style={{ position: 'relative' }}>
<Image
source={{ uri: resolvedPreview }}
style={{
width: 96,
height: 96,
borderRadius: 48,
backgroundColor: '#e5e5e5',
borderWidth: 2,
borderColor: colors.border,
}}
/>
{uploading ? (
<View
style={{
position: 'absolute',
inset: 0,
borderRadius: 48,
backgroundColor: 'rgba(0,0,0,0.45)',
alignItems: 'center',
justifyContent: 'center',
}}
>
<ActivityIndicator color="#fff" size="small" />
</View>
) : null}
</View>
<Pressable
onPress={pickPhoto}
style={({ pressed }) => ({
marginTop: 12,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
opacity: pressed ? 0.5 : 1,
})}
>
<Ionicons name="camera-outline" size={18} color={colors.brandOrange} />
<Text
style={{
fontSize: 14,
color: colors.brandOrange,
fontFamily: 'Nunito_600SemiBold',
}}
>
{t('profile.edit_photo_cta')}
</Text>
</Pressable>
</View>
{/* Preset avatars */}
<View style={{ paddingHorizontal: 16, marginBottom: 24 }}>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.8,
marginBottom: 12,
}}
>
{t('profile.edit_preset_label').toUpperCase()}
</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}>
{HERO_AVATARS.map((avatar) => {
const isSelected = !photoUri && avatarId === avatar.id;
return (
<Pressable
key={avatar.id}
onPress={() => {
setAvatarId(avatar.id);
setPhotoUri(null);
}}
style={({ pressed }) => ({
opacity: pressed ? 0.7 : 1,
})}
>
<Image
source={{ uri: getAvatarUrl(avatar.id) }}
style={{
width: 56,
height: 56,
borderRadius: 28,
borderWidth: isSelected ? 3 : 1.5,
borderColor: isSelected ? colors.brandOrange : colors.border,
opacity: isSelected ? 1 : 0.55,
}}
/>
</Pressable>
);
})}
</View>
</View>
{/* Divider */}
<View style={{ height: 1, backgroundColor: colors.border, marginHorizontal: 16, marginBottom: 24 }} />
{/* Nickname */}
<View style={{ paddingHorizontal: 16 }}>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.8,
marginBottom: 8,
}}
>
{t('profile.edit_nickname_label').toUpperCase()}
</Text>
<TextInput
style={INPUT_STYLE}
value={nickname}
onChangeText={setNickname}
placeholder={t('auth.nicknamePlaceholder')}
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoCorrect={false}
maxLength={32}
returnKeyType="done"
/>
<Text
style={{
marginTop: 6,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{t('profile.edit_nickname_hint')}
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}

View File

@ -1,99 +1,29 @@
import { useRef, useState } from 'react';
import { View, ScrollView, Text, Alert, findNodeHandle, UIManager } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { AppHeader } from '../../components/AppHeader';
import { ProfileHeader, type AuthProvider } from '../../components/profile/ProfileHeader';
import { StatsBar } from '../../components/profile/StatsBar';
import { ApprovedDomainsList, type ApprovedDomain } from '../../components/profile/ApprovedDomainsList';
import { ApprovedDomainsList } from '../../components/profile/ApprovedDomainsList';
import { StreakSection, type CooldownEntry } from '../../components/profile/StreakSection';
import { UrgeStatsCard, type HelpedByEntry } from '../../components/profile/UrgeStatsCard';
import { DemographicsAccordion, type Demographics } from '../../components/profile/DemographicsAccordion';
import { DigaMissionBanner } from '../../components/profile/DigaMissionBanner';
import { colors } from '../../lib/theme';
import { useColors } from '../../lib/theme';
import type { Plan } from '../../hooks/useUserPlan';
import { useMe } from '../../hooks/useMe';
import { useAuthStore } from '../../stores/auth';
import {
useSocialStats,
useApprovedDomains,
useCooldownHistory,
useSosInsights,
useDemographics,
} from '../../hooks/useProfileData';
import { apiFetch } from '../../lib/api';
// TODO Phase C: GET /api/profile/me — aggregate endpoint (profile + stats + streak +
// recentCooldowns + demographics + sosInsights). Until backend live:
// - Core User-Felder (nickname/email/avatar/plan) kommen aus useMe-Hook (live)
// - Stats/Streak/Cooldowns/Demographics bleiben dummy
const DUMMY_PROFILE_FALLBACK = {
memberSince: 'April 2026', // TODO Phase C: aus profile.created_at
provider: 'email' as AuthProvider, // TODO Phase C: aus user.app_metadata.provider
};
const DUMMY_STATS = {
postsCount: 12,
followersCount: 47,
// Approved Domains = Community-Beitrag (KEIN Plan-Slot, kein Cap).
// Source: domain_submissions WHERE userId=me AND status='approved'.
// TODO Phase C: GET /api/profile/me/approved-domains (Endpoint existiert noch NICHT
// — gefunden wurden nur admin-side aggregate counts in
// backend/server/api/admin/stats.get.ts und backend/server/api/blocklist/stats.get.ts).
// Neuer Endpoint nötig: GET /api/profile/me/approved-domains → { count, list[] }.
approvedDomainsCount: 5,
};
const DUMMY_STREAK = {
currentDays: 23,
longestDays: 41,
startDate: '14. April 2026',
};
// TODO: GET /api/profile/me/cooldown-history?cursor=...
const DUMMY_COOLDOWNS: CooldownEntry[] = [
{
id: 'c1',
startedAt: '06.05.',
durationLabel: '24h',
status: 'active',
reason: null,
},
{
id: 'c2',
startedAt: '02.05.',
durationLabel: '4h',
status: 'cancelled',
reason: null,
},
{
id: 'c3',
startedAt: '18.04.',
durationLabel: '16h',
status: 'resolved',
reason: 'Stress nach Arbeit',
},
];
// TODO: GET /api/profile/me/approved-domains
const DUMMY_APPROVED_DOMAINS: ApprovedDomain[] = [
{ domain: 'tipico.de', approvedAt: '12.04.' },
{ domain: 'bwin.com', approvedAt: '15.04.' },
{ domain: 'merkur24.com', approvedAt: '20.04.' },
{ domain: 'sunmaker.com', approvedAt: '28.04.' },
{ domain: 'lottoland.com', approvedAt: '02.05.' },
];
// TODO: GET /api/profile/me/sos-insights
const DUMMY_HELPED_BY: HelpedByEntry[] = [
{ key: 'breathing', label: 'Atemübung', count: 3 },
{ key: 'game', label: 'Spiel', count: 1 },
{ key: 'talk', label: 'Reden mit Lyra', count: 1 },
];
// TODO: GET /api/profile/me/demographics — gehört zur me-aggregat-response
const DUMMY_DEMOGRAPHICS: Demographics = {
birthYear: 1989,
gender: 'diverse',
maritalStatus: null,
employmentStatus: null,
shiftWork: null,
industry: null,
jobTenure: null,
bundesland: 'BY',
city: null,
};
const EMPTY_COOLDOWNS: CooldownEntry[] = [];
function isDemographicsComplete(d: Demographics): boolean {
const base =
@ -114,32 +44,90 @@ function isDemographicsComplete(d: Demographics): boolean {
return true;
}
const EMPTY_DEMOGRAPHICS: Demographics = {
birthYear: null,
gender: null,
maritalStatus: null,
employmentStatus: null,
shiftWork: null,
industry: null,
jobTenure: null,
bundesland: null,
city: null,
};
function formatMemberSince(isoString: string | undefined): string {
if (!isoString) return '';
const d = new Date(isoString);
return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
}
function formatStreakStartDate(isoString: string | undefined): string {
if (!isoString) return '';
const d = new Date(isoString);
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' });
}
function mapHelpedBy(helpedBy: {
breathing: number;
game: number;
talk: number;
other: number;
}): HelpedByEntry[] {
const entries: HelpedByEntry[] = [
{ key: 'breathing', label: 'Atemübung', count: helpedBy.breathing },
{ key: 'game', label: 'Spiel', count: helpedBy.game },
{ key: 'talk', label: 'Reden mit Lyra', count: helpedBy.talk },
{ key: 'other', label: 'Sonstiges', count: helpedBy.other },
];
return entries.filter((e) => e.count > 0);
}
export default function ProfileScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const colors = useColors();
const [bannerDismissed, setBannerDismissed] = useState(false);
const [demographics, setDemographics] = useState<Demographics>(DUMMY_DEMOGRAPHICS);
const [demographicsExpanded, setDemographicsExpanded] = useState(false);
const { me } = useMe();
const { user } = useAuthStore();
const { stats: socialStats } = useSocialStats(me?.id);
const { domains: approvedDomainsData } = useApprovedDomains();
const { cooldownHistory } = useCooldownHistory();
const { sosInsights } = useSosInsights();
const {
demographics: serverDemographics,
withdrawnAt,
reload: reloadDemographics,
} = useDemographics();
const demographics: Demographics = serverDemographics ?? EMPTY_DEMOGRAPHICS;
const scrollViewRef = useRef<ScrollView | null>(null);
const demographicsAnchorRef = useRef<View | null>(null);
// Live-Daten aus DB (für Avatar / Nickname / Plan / Email).
// Provider-Detection: user.app_metadata.provider vom Supabase-OAuth-Flow.
const provider: AuthProvider =
((user?.app_metadata as { provider?: string } | undefined)?.provider as AuthProvider) ?? 'email';
const profile = {
nickname: me?.nickname ?? user?.email?.split('@')[0] ?? 'User',
email: user?.email ?? '',
avatar: me?.avatar ?? null,
plan: ((me as { plan?: string } | null | undefined)?.plan ?? 'free') as Plan,
memberSince: DUMMY_PROFILE_FALLBACK.memberSince,
memberSince: formatMemberSince(me?.created_at),
provider,
};
const showDigaBanner = DUMMY_STREAK.currentDays >= 30 && !bannerDismissed;
const demoComplete = isDemographicsComplete(demographics);
const currentStreak = me?.streak ?? 0;
// TODO(backend): longestDays + streakStartDate fehlen in /api/auth/me.
// Backend-Agent: Profile-Tabelle braucht longestStreak:Int + streakStartedAt:DateTime.
// Tracking: streakStartedAt wird bei jedem Streak-Reset auf NOW() gesetzt.
const longestDays = currentStreak;
const streakStartDate = formatStreakStartDate(me?.created_at);
const showDigaBanner = currentStreak >= 30 && !bannerDismissed;
const demoComplete = !withdrawnAt && isDemographicsComplete(demographics);
function scrollToDemographics() {
const node = demographicsAnchorRef.current;
@ -151,9 +139,7 @@ export default function ProfileScreen() {
UIManager.measureLayout(
handle,
scrollHandle,
() => {
// measure failure — silent
},
() => {},
(_x, y) => {
scroll.scrollTo({ y: Math.max(0, y - 16), animated: true });
},
@ -166,7 +152,7 @@ export default function ProfileScreen() {
}
return (
<View style={{ flex: 1, backgroundColor: '#fafafa' }}>
<View style={{ flex: 1, backgroundColor: colors.bg }}>
<AppHeader showBack title="Profil" />
<ScrollView
ref={scrollViewRef}
@ -184,50 +170,34 @@ export default function ProfileScreen() {
demographicsComplete={demoComplete}
showDemographicsHint={!demoComplete}
onDemographicsHintPress={openDemographics}
onEditAvatar={() => {
Alert.alert(
'Avatar bearbeiten',
'Hero-Auswahl + Foto-Upload kommt in der nächsten Iteration.',
);
}}
onEditNickname={() => {
Alert.alert(
'Nickname bearbeiten',
'Inline-Edit + Save kommt in der nächsten Iteration.',
);
}}
onEditAvatar={() => router.push('/profile/edit')}
onEditNickname={() => router.push('/profile/edit')}
/>
<View
style={{
height: 1,
backgroundColor: 'rgba(0,0,0,0.06)',
backgroundColor: colors.border,
marginHorizontal: 16,
}}
/>
<View style={{ marginTop: 16 }}>
<StatsBar
postsCount={DUMMY_STATS.postsCount}
followersCount={DUMMY_STATS.followersCount}
approvedDomainsCount={DUMMY_STATS.approvedDomainsCount}
onPostsPress={() => {
// TODO: Phase C — navigate to user's own posts list
}}
onFollowersPress={() => {
// TODO: Phase C — open FollowersSheet
}}
onApprovedDomainsPress={() => {
// TODO: Phase C — scroll to ApprovedDomainsList + auto-expand
}}
postsCount={socialStats?.postsCount ?? 0}
followersCount={socialStats?.followersCount ?? 0}
approvedDomainsCount={approvedDomainsData?.count ?? 0}
onPostsPress={() => {}}
onFollowersPress={() => {}}
onApprovedDomainsPress={() => {}}
/>
</View>
{showDigaBanner ? (
<DigaMissionBanner
onDismiss={() => {
// TODO: AsyncStorage persist `diga_banner_dismissed_at`
setBannerDismissed(true);
apiFetch('/api/profile/me/diga-banner-dismiss', { method: 'POST' }).catch(() => {});
}}
onContribute={() => {
setBannerDismissed(true);
@ -237,53 +207,69 @@ export default function ProfileScreen() {
) : null}
<StreakSection
currentDays={DUMMY_STREAK.currentDays}
longestDays={DUMMY_STREAK.longestDays}
startDate={DUMMY_STREAK.startDate}
cooldowns={DUMMY_COOLDOWNS}
currentDays={currentStreak}
longestDays={longestDays}
startDate={streakStartDate}
cooldowns={cooldownHistory?.items ?? EMPTY_COOLDOWNS}
/>
<UrgeStatsCard
sessions={5}
overcome={4}
helpedBy={DUMMY_HELPED_BY}
topEmotion="Stress"
sessions={sosInsights?.last30Days.sessions ?? 0}
overcome={sosInsights?.last30Days.overcome ?? 0}
helpedBy={
sosInsights
? mapHelpedBy(sosInsights.helpedBy)
: []
}
topEmotion={sosInsights?.topEmotion ?? null}
/>
{/* Anchor: Hint-Tap im Header scrollt hierhin */}
<View ref={demographicsAnchorRef} collapsable={false}>
<DemographicsAccordion
demographics={demographics}
plan={profile.plan}
expanded={demographicsExpanded}
onChange={(next) => {
// TODO Phase C: PATCH /api/profile/me/demographics — Body: next
// Endpoint: profile.demographics_consent_at = NOW() bei erstem Save (DSGVO-Audit-Trail).
// Plan-Trial-Trigger: wenn alle 6 Felder gefüllt + plan='free' → server setzt
// pro_trial_started_at + pro_trial_expires_at + pro_trial_source='demographics_complete'.
setDemographics(next);
onChange={async (next) => {
try {
const result = await apiFetch<{ trialAwarded: boolean; expiresAt: string | null }>(
'/api/profile/me/demographics',
{ method: 'PATCH', body: next },
);
reloadDemographics();
if (result.trialAwarded) {
Alert.alert(
'Pro-Woche freigeschaltet',
'Danke fur deine DiGA-Daten. Du hast 7 Tage Pro kostenlos erhalten.',
);
}
} catch {
// write failed — optimistic update not applied, server state preserved
}
}}
onRevokeConsent={() => {
// TODO: Phase C — DELETE /api/profile/me/demographics, confirm-alert first
Alert.alert(
'Daten zuruckziehen',
'Alle demografischen Angaben werden geloscht. Fortfahren?',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Loschen',
style: 'destructive',
onPress: () => {
apiFetch('/api/profile/me/demographics', { method: 'DELETE' })
.then(() => reloadDemographics())
.catch(() => {});
},
},
],
);
}}
/>
</View>
{/* ApprovedDomains ans Ende — User-Direktive 2026-05-08 */}
<ApprovedDomainsList domains={DUMMY_APPROVED_DOMAINS} />
<ApprovedDomainsList domains={approvedDomainsData?.list ?? []} />
<View style={{ height: 24 }} />
<Text
style={{
textAlign: 'center',
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
opacity: 0.6,
}}
>
Profil-Skeleton (dummy data) Backend wired in Phase C
</Text>
</ScrollView>
</View>
);

View File

@ -20,13 +20,14 @@ import { Ionicons } from '@expo/vector-icons';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system';
// TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14
import * as FileSystem from 'expo-file-system/legacy';
import { apiFetch } from '../lib/api';
import { supabase } from '../lib/supabase';
import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble';
import { ChatInput, type SendPayload } from '../components/chat/ChatInput';
import { useRoomRealtime } from '../hooks/useChatRealtime';
import { colors } from '../lib/theme';
import { useColors } from '../lib/theme';
const GROUP_GAP_MS = 5 * 60 * 1000;
@ -63,6 +64,8 @@ export default function RoomScreen() {
const { t } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const colors = useColors();
const styles = makeStyles(colors);
const queryClient = useQueryClient();
const flatRef = useRef<FlatList>(null);
const [myUserId, setMyUserId] = useState<string | undefined>();
@ -297,7 +300,7 @@ export default function RoomScreen() {
{/* Header */}
<View style={styles.header}>
<Pressable style={styles.iconBtn} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color="#0a0a0a" />
<Ionicons name="chevron-back" size={22} color={colors.text} />
</Pressable>
<View style={styles.headerCenter}>
<View style={styles.headerAvatar}>
@ -319,7 +322,7 @@ export default function RoomScreen() {
</View>
</View>
<Pressable style={styles.iconBtn} onPress={() => setSettingsOpen(true)} hitSlop={8}>
<Ionicons name="ellipsis-horizontal" size={20} color="#0a0a0a" />
<Ionicons name="ellipsis-horizontal" size={20} color={colors.text} />
</Pressable>
</View>
@ -429,6 +432,8 @@ function RoomSettingsModal({
roomId: string;
}) {
const { t } = useTranslation();
const colors = useColors();
const modal = makeModalStyles(colors);
const [pendingRequests, setPendingRequests] = useState<any[]>([]);
const [loadingReqs, setLoadingReqs] = useState(false);
@ -636,22 +641,23 @@ function RoomSettingsModal({
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fafafa' },
function makeStyles(colors: ReturnType<typeof useColors>) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: '#fff',
backgroundColor: colors.bg,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#e5e5e5',
borderBottomColor: colors.border,
},
iconBtn: {
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: '#f5f5f5',
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
},
@ -665,7 +671,7 @@ const styles = StyleSheet.create({
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#e5e5e5',
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
@ -675,17 +681,17 @@ const styles = StyleSheet.create({
headerAvatarInitials: {
fontSize: 12,
fontFamily: 'Nunito_700Bold',
color: '#737373',
color: colors.textMuted,
},
headerName: {
fontSize: 15,
fontFamily: 'Nunito_700Bold',
color: '#171717',
color: colors.text,
},
headerSub: {
fontSize: 11,
fontFamily: 'Nunito_500Medium',
color: '#737373',
color: colors.textMuted,
marginTop: 1,
},
loadingBox: { flex: 1, alignItems: 'center', justifyContent: 'center' },
@ -698,20 +704,20 @@ const styles = StyleSheet.create({
joinTitle: {
fontSize: 20,
fontFamily: 'Nunito_700Bold',
color: '#171717',
color: colors.text,
marginTop: 14,
},
joinDesc: {
fontSize: 13,
fontFamily: 'Nunito_500Medium',
color: '#737373',
color: colors.textMuted,
marginTop: 6,
textAlign: 'center',
},
joinHint: {
fontSize: 12,
fontFamily: 'Nunito_500Medium',
color: '#a3a3a3',
color: colors.textMuted,
marginTop: 18,
textAlign: 'center',
},
@ -744,23 +750,25 @@ const styles = StyleSheet.create({
fontFamily: 'Nunito_700Bold',
marginLeft: 6,
},
});
});
}
const modal = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fafafa' },
function makeModalStyles(colors: ReturnType<typeof useColors>) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#fff',
backgroundColor: colors.bg,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#e5e5e5',
borderBottomColor: colors.border,
},
title: { fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#171717' },
title: { fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text },
section: {
backgroundColor: '#fff',
backgroundColor: colors.surface,
borderRadius: 12,
padding: 14,
marginBottom: 12,
@ -768,7 +776,7 @@ const modal = StyleSheet.create({
sectionTitle: {
fontSize: 12,
fontFamily: 'Nunito_700Bold',
color: '#737373',
color: colors.textMuted,
textTransform: 'uppercase',
marginBottom: 10,
letterSpacing: 0.5,
@ -776,7 +784,7 @@ const modal = StyleSheet.create({
avatarWrap: { alignSelf: 'center', marginBottom: 10 },
avatar: { width: 80, height: 80, borderRadius: 40 },
avatarPlaceholder: {
backgroundColor: '#e5e5e5',
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
},
@ -789,20 +797,20 @@ const modal = StyleSheet.create({
borderRadius: 14,
backgroundColor: '#007AFF',
borderWidth: 3,
borderColor: '#fff',
borderColor: colors.bg,
alignItems: 'center',
justifyContent: 'center',
},
roomName: {
fontSize: 17,
fontFamily: 'Nunito_700Bold',
color: '#171717',
color: colors.text,
textAlign: 'center',
},
roomDesc: {
fontSize: 12,
fontFamily: 'Nunito_500Medium',
color: '#737373',
color: colors.textMuted,
textAlign: 'center',
marginTop: 4,
},
@ -811,13 +819,13 @@ const modal = StyleSheet.create({
alignItems: 'center',
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#f5f5f5',
borderBottomColor: colors.border,
},
memberAvatar: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#e5e5e5',
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
@ -827,17 +835,17 @@ const modal = StyleSheet.create({
memberInitials: {
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: '#737373',
color: colors.textMuted,
},
memberName: { fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: '#171717' },
memberRole: { fontSize: 11, color: '#a3a3a3', marginTop: 1, textTransform: 'capitalize' },
memberName: { fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text },
memberRole: { fontSize: 11, color: colors.textMuted, marginTop: 1, textTransform: 'capitalize' },
actionBtn: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 6,
},
actionText: { fontSize: 11, fontFamily: 'Nunito_700Bold' },
emptyText: { fontSize: 12, color: '#a3a3a3' },
emptyText: { fontSize: 12, color: colors.textMuted },
leaveBtn: {
flexDirection: 'row',
alignItems: 'center',
@ -853,4 +861,5 @@ const modal = StyleSheet.create({
fontFamily: 'Nunito_700Bold',
marginLeft: 6,
},
});
});
}

View File

@ -6,13 +6,15 @@ import {
Text,
View,
} from 'react-native';
import { useState } from 'react';
import { useRef, useState } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useNativeActionSheet } from '../lib/useNativeActionSheet';
import { MenuView, type MenuAction } from '@react-native-menu/menu';
import { TrueSheet } from '@lodev09/react-native-true-sheet';
import { useTranslation } from 'react-i18next';
import { colors } from '../lib/theme';
import { LanguageIcon } from '../components/icons/LanguageIcon';
import { useColors } from '../lib/theme';
import { useAuthStore } from '../stores/auth';
import { useThemeStore, type ThemeMode } from '../stores/theme';
import { useLanguageStore, type AppLanguage } from '../stores/language';
@ -24,13 +26,20 @@ import { AppHeader } from '../components/AppHeader';
type PickerOption<T extends string> = { value: T; label: string };
type SectionRow = {
icon: React.ComponentProps<typeof Ionicons>['name'];
/** Ionicons-name ODER eigenes SVG-Component (für custom icons wie LanguageIcon) */
icon: React.ComponentProps<typeof Ionicons>['name'] | React.ComponentType<{ size?: number; color?: string }>;
label: string;
sublabel: string;
soon?: boolean;
destructive?: boolean;
value?: string;
onPress?: () => void;
/** Wenn gesetzt, wrappt UIMenu (anchored Pull-Down) statt onPress-trigger */
menu?: {
title: string;
actions: MenuAction[];
onSelect: (id: string) => void;
};
};
type Section = {
@ -47,7 +56,7 @@ export default function SettingsScreen() {
const { mode: themeMode, setMode: setThemeMode } = useThemeStore();
const { language, setLanguage } = useLanguageStore();
const { plan } = useUserPlan();
const { showActionSheetWithOptions } = useNativeActionSheet();
const colors = useColors();
// Lyra Voice: hardcoded ElevenLabs voice IDs (expandable by user later)
// Backend endpoint PATCH /api/profile/me/demographics does NOT accept lyraVoiceId.
@ -55,24 +64,8 @@ export default function SettingsScreen() {
// For now: picker is wired to local state only, changes are NOT persisted.
const [selectedVoice, setSelectedVoice] = useState('EXAVITQu4vr4xnSDxMaL');
function pickFromOptions<T extends string>(
title: string,
options: PickerOption<T>[],
onPick: (value: T) => void,
) {
const labels = options.map((o) => o.label);
showActionSheetWithOptions(
{
title,
options: [...labels, t('common.cancel')],
cancelButtonIndex: labels.length,
},
(idx) => {
if (idx === undefined || idx === labels.length) return;
onPick(options[idx].value);
},
);
}
// TrueSheet ref for Lyra-Voice picker (UISheetPresentationController bottom-sheet)
const voiceSheetRef = useRef<TrueSheet>(null);
async function handleSignOut() {
Alert.alert(t('auth.signOut'), '', [
@ -128,20 +121,32 @@ export default function SettingsScreen() {
label: t('settings.theme'),
sublabel: t('settings.theme_desc'),
value: themeLabel,
onPress: () =>
pickFromOptions<ThemeMode>(t('settings.theme'), themeOptions, (v) =>
setThemeMode(v),
),
menu: {
title: t('settings.theme'),
// Bewusst KEINE `image`-Props (SF-Symbols) — sonst rendert UIMenu mit
// Icon-Slot reserviert und das Menu wird breiter/höher als bei Sprache.
actions: themeOptions.map((opt) => ({
id: opt.value,
title: opt.label,
state: opt.value === themeMode ? 'on' : 'off',
})),
onSelect: (id) => setThemeMode(id as ThemeMode),
},
},
{
icon: 'language-outline',
icon: LanguageIcon,
label: t('settings.language'),
sublabel: t('settings.language_desc'),
value: language === 'de' ? t('settings.language_de') : t('settings.language_en'),
onPress: () =>
pickFromOptions<AppLanguage>(t('settings.language'), langOptions, (v) =>
setLanguage(v),
),
menu: {
title: t('settings.language'),
actions: langOptions.map((opt) => ({
id: opt.value,
title: opt.label,
state: opt.value === language ? 'on' : 'off',
})),
onSelect: (id) => setLanguage(id as AppLanguage),
},
},
],
},
@ -171,7 +176,7 @@ export default function SettingsScreen() {
icon: 'phone-portrait-outline',
label: t('settings.devices'),
sublabel: t('settings.devices_desc'),
soon: true,
onPress: () => router.push('/devices'),
},
{
icon: 'star-outline',
@ -196,14 +201,7 @@ export default function SettingsScreen() {
// Voice picker is wired but changes are local-only until
// PATCH /api/profile/me/lyra-voice endpoint is added by backend-agent.
onPress:
plan === 'legend'
? () =>
pickFromOptions<string>(
t('settings.lyra_voice'),
voiceOptions,
(v) => setSelectedVoice(v),
)
: undefined,
plan === 'legend' ? () => voiceSheetRef.current?.present() : undefined,
soon: plan !== 'legend',
},
],
@ -251,7 +249,7 @@ export default function SettingsScreen() {
}
return (
<View style={{ flex: 1, backgroundColor: '#fafafa' }}>
<View style={{ flex: 1, backgroundColor: colors.bg }}>
<AppHeader showBack title={t('settings.title')} />
<ScrollView
@ -268,7 +266,7 @@ export default function SettingsScreen() {
<Text
style={{
fontSize: 11,
color: '#a3a3a3',
color: colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
textTransform: 'uppercase',
letterSpacing: 1,
@ -280,7 +278,7 @@ export default function SettingsScreen() {
</Text>
<View
style={{
backgroundColor: '#ffffff',
backgroundColor: colors.surface,
borderRadius: 14,
overflow: 'hidden',
shadowColor: '#000',
@ -290,32 +288,21 @@ export default function SettingsScreen() {
elevation: 1,
}}
>
{section.rows.map((row, i) => (
<Pressable
key={row.label}
onPress={row.soon ? undefined : row.onPress}
disabled={row.soon}
style={({ pressed }) => ({
opacity: row.soon ? 0.5 : pressed ? 0.7 : 1,
})}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 14,
paddingVertical: 12,
minHeight: 56,
borderBottomWidth: i < section.rows.length - 1 ? 1 : 0,
borderBottomColor: 'rgba(0,0,0,0.04)',
}}
>
{section.rows.map((row, i) => {
// Visual content of the row (icon + label + sublabel)
const iconColor = row.destructive ? colors.error : colors.textMuted;
const IconComponent = typeof row.icon === 'string' ? null : row.icon;
const rowLeft = (
<>
{IconComponent ? (
<IconComponent size={18} color={iconColor} />
) : (
<Ionicons
name={row.icon}
name={row.icon as React.ComponentProps<typeof Ionicons>['name']}
size={18}
color={row.destructive ? colors.error : colors.textMuted}
color={iconColor}
/>
)}
<View style={{ flex: 1 }}>
<Text
numberOfLines={1}
@ -341,11 +328,90 @@ export default function SettingsScreen() {
</Text>
) : null}
</View>
</>
);
const containerStyle = {
flexDirection: 'row' as const,
alignItems: 'center' as const,
gap: 12,
paddingHorizontal: 14,
paddingVertical: 12,
minHeight: 56,
borderBottomWidth: i < section.rows.length - 1 ? 1 : 0,
borderBottomColor: colors.border,
opacity: row.soon ? 0.5 : 1,
};
// Row mit Menu: Label-Bereich nicht tappable, MenuView nur am End-Anchor
if (row.menu) {
return (
<View key={row.label} style={containerStyle}>
{rowLeft}
<MenuView
title={row.menu.title}
actions={row.menu.actions}
onPressAction={({ nativeEvent: { event } }) =>
row.menu!.onSelect(event)
}
shouldOpenOnLongPress={false}
>
<Pressable
hitSlop={8}
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 8,
paddingVertical: 6,
borderRadius: 8,
backgroundColor: colors.surfaceElevated,
}}
>
{row.value ? (
<Text
style={{
fontSize: 13,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
numberOfLines={1}
>
{row.value}
</Text>
) : null}
<Ionicons
name="chevron-forward"
size={14}
color={colors.textMuted}
/>
</View>
</Pressable>
</MenuView>
</View>
);
}
// Standard-Row: ganze Pressable als Tap-Target
return (
<Pressable
key={row.label}
onPress={row.soon ? undefined : row.onPress}
disabled={row.soon}
style={({ pressed }) => ({
opacity: row.soon ? 0.5 : pressed ? 0.7 : 1,
})}
>
<View style={containerStyle}>
{rowLeft}
{row.soon ? (
<Text
style={{
fontSize: 10,
color: '#a3a3a3',
color: colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
textTransform: 'uppercase',
letterSpacing: 0.5,
@ -369,12 +435,13 @@ export default function SettingsScreen() {
<Ionicons
name="chevron-forward"
size={16}
color="#d4d4d8"
color={colors.border}
/>
)}
</View>
</Pressable>
))}
);
})}
</View>
</View>
))}
@ -383,7 +450,7 @@ export default function SettingsScreen() {
style={{
textAlign: 'center',
fontSize: 11,
color: '#a3a3a3',
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 6,
opacity: 0.7,
@ -395,7 +462,7 @@ export default function SettingsScreen() {
style={{
textAlign: 'center',
fontSize: 10,
color: '#a3a3a3',
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 4,
opacity: 0.5,
@ -404,6 +471,75 @@ export default function SettingsScreen() {
{Platform.OS}
</Text>
</ScrollView>
<TrueSheet
ref={voiceSheetRef}
detents={['auto', 1]}
cornerRadius={20}
grabber
backgroundColor={colors.surface}
>
<View style={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 24, backgroundColor: colors.surface }}>
<Text
style={{
fontSize: 22,
color: colors.text,
fontFamily: 'Nunito_700Bold',
marginBottom: 8,
}}
>
{t('settings.lyra_voice')}
</Text>
<Text
style={{
fontSize: 13,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginBottom: 20,
lineHeight: 18,
}}
>
{t('settings.lyra_voice_desc')}
</Text>
{voiceOptions.map((opt, idx) => {
const isSelected = opt.value === selectedVoice;
return (
<Pressable
key={opt.value}
onPress={() => {
setSelectedVoice(opt.value);
voiceSheetRef.current?.dismiss();
}}
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
borderBottomWidth: idx < voiceOptions.length - 1 ? 1 : 0,
borderBottomColor: colors.border,
}}
>
<Text
style={{
fontSize: 16,
color: colors.text,
fontFamily: isSelected ? 'Nunito_700Bold' : 'Nunito_400Regular',
}}
>
{opt.label}
</Text>
{isSelected ? (
<Ionicons name="checkmark" size={20} color={colors.brandOrange} />
) : null}
</View>
</Pressable>
);
})}
</View>
</TrueSheet>
</View>
);
}

View File

@ -8,13 +8,14 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from 'expo-av';
import * as FileSystem from 'expo-file-system';
// TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14
import * as FileSystem from 'expo-file-system/legacy';
import Constants from 'expo-constants';
import { useTranslation } from 'react-i18next';
import { RiveAvatar } from '../components/RiveAvatar';
import { apiFetch } from '../lib/api';
import { supabase } from '../lib/supabase';
import { colors } from '../lib/theme';
import { useColors } from '../lib/theme';
import {
type GameType, GAME_META, MemoryGame, TicTacToeGame, SnakeGame, TetrisGame,
} from '../components/urge/UrgeGames';
@ -40,6 +41,8 @@ export default function SOSScreen() {
const { t, i18n } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const colors = useColors();
const st = makeStyles(colors);
const flatRef = useRef<FlatList>(null);
const [messages, setMessages] = useState<SosMsg[]>([]);
@ -237,8 +240,8 @@ export default function SOSScreen() {
const session = (await supabase.auth.getSession()).data.session;
if (controller.signal.aborted) return null;
const apiBase = Constants.expoConfig?.extra?.apiUrl as string;
const endpoint = endpointForProvider(currentProvider());
const isGoogleCloud = endpoint.endsWith('/speak-google');
const endpoint = '/api/coach/speak';
const isGoogleCloud = false;
const ttsRes = await fetch(`${apiBase}${endpoint}`, {
method: 'POST',
headers: {
@ -441,7 +444,7 @@ export default function SOSScreen() {
apiBase,
accessToken: session.access_token,
locale: i18n.language,
endpoint: endpointForProvider(currentProvider()),
endpoint: '/api/coach/speak',
onStart: () => { setIsSpeaking(true); setIsTtsLoading(false); },
onIdle: () => { setIsSpeaking(false); setIsTtsLoading(false); scheduleEmotionReset(0); bench.print(); },
onError: (err, sentence) => {
@ -628,7 +631,7 @@ export default function SOSScreen() {
apiBase,
accessToken: session.access_token,
locale: i18n.language,
endpoint: endpointForProvider(currentProvider()),
endpoint: '/api/coach/speak',
onStart: () => { setIsSpeaking(true); setIsTtsLoading(false); },
onIdle: () => { setIsSpeaking(false); setIsTtsLoading(false); scheduleEmotionReset(0); greetingBench.print(); },
onError: (err, sentence) => {
@ -1088,7 +1091,7 @@ export default function SOSScreen() {
{/* Header */}
<View style={[st.topBar, { top: insets.top + 6 }]}>
<Pressable style={st.actionBtn} onPress={attemptExit} hitSlop={12}>
<Ionicons name="close" size={22} color="#374151" />
<Ionicons name="close" size={22} color={colors.textMuted} />
</Pressable>
<View style={st.avatarCenter}>
<RiveAvatar emotion={emotion} size="md" />
@ -1148,10 +1151,10 @@ export default function SOSScreen() {
<View style={{ flex: 1 }}>
<GameHeader game={playingGame} emotion={emotion} onBack={handleGameAbandon} />
<View style={{ flex: 1, padding: 14 }}>
{playingGame === 'memory' && <MemoryGame onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
{playingGame === 'tictactoe' && <TicTacToeGame onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
{playingGame === 'snake' && <SnakeGame onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
{playingGame === 'tetris' && <TetrisGame onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
{playingGame === 'memory' && <MemoryGame mode="sos" onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
{playingGame === 'tictactoe' && <TicTacToeGame mode="sos" onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
{playingGame === 'snake' && <SnakeGame mode="sos" onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
{playingGame === 'tetris' && <TetrisGame mode="sos" onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
</View>
</View>
) : (
@ -1308,23 +1311,24 @@ export default function SOSScreen() {
);
}
const st = StyleSheet.create({
container: { flex: 1, backgroundColor: '#ffffff' },
function makeStyles(colors: ReturnType<typeof useColors>) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg },
topBar: { position: 'absolute', left: 0, right: 0, zIndex: 10, flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', paddingHorizontal: 12 },
topBarBackdrop: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 9, backgroundColor: '#ffffff' },
actionBtn: { width: 40, height: 40, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.92)', alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.08, shadowRadius: 6, elevation: 4 },
topBarBackdrop: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 9, backgroundColor: colors.bg },
actionBtn: { width: 40, height: 40, borderRadius: 20, backgroundColor: colors.surface, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.08, shadowRadius: 6, elevation: 4 },
avatarCenter: { flex: 1, alignItems: 'center', gap: 4 },
avatarMeta: { alignItems: 'center', gap: 2 },
avatarName: { fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' },
avatarName: { fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text },
speakingRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
stopBtn: { width: 18, height: 18, borderRadius: 9, backgroundColor: '#f5f5f5', alignItems: 'center', justifyContent: 'center' },
stopBtn: { width: 18, height: 18, borderRadius: 9, backgroundColor: colors.surfaceElevated, alignItems: 'center', justifyContent: 'center' },
listContent: { paddingHorizontal: 12, paddingBottom: 4 },
scrollDownBtn: { position: 'absolute', bottom: 8, right: 16, width: 36, height: 36, borderRadius: 18, backgroundColor: '#374151', alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 4, elevation: 4 },
chip: {
borderRadius: 14,
borderWidth: 1.5,
borderColor: '#9ca3af', // sichtbarer Ring (medium-grau gegen weiß)
backgroundColor: '#ffffff',
borderColor: colors.border,
backgroundColor: colors.bg,
paddingHorizontal: 16,
paddingVertical: 11,
shadowColor: '#000',
@ -1334,13 +1338,14 @@ const st = StyleSheet.create({
elevation: 3,
},
chipPressed: {
backgroundColor: '#f3f4f6',
borderColor: '#6b7280', // dunkler beim Press → spürbares Feedback
backgroundColor: colors.surfaceElevated,
borderColor: colors.textMuted,
transform: [{ scale: 0.97 }],
shadowOpacity: 0.05,
},
chipText: { fontFamily: 'Nunito_600SemiBold', fontSize: 14, color: '#334155' },
inputBar: { flexDirection: 'row', alignItems: 'flex-end', paddingHorizontal: 12, paddingTop: 8, borderTopWidth: 1, borderTopColor: '#f3f4f6', backgroundColor: '#fff', gap: 8 },
textInput: { flex: 1, minHeight: 40, maxHeight: 120, backgroundColor: '#f3f4f6', borderRadius: 20, paddingHorizontal: 14, paddingVertical: 10, fontSize: 15, fontFamily: 'Nunito_400Regular', color: '#111827' },
chipText: { fontFamily: 'Nunito_600SemiBold', fontSize: 14, color: colors.textMuted },
inputBar: { flexDirection: 'row', alignItems: 'flex-end', paddingHorizontal: 12, paddingTop: 8, borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.bg, gap: 8 },
textInput: { flex: 1, minHeight: 40, maxHeight: 120, backgroundColor: colors.surfaceElevated, borderRadius: 20, paddingHorizontal: 14, paddingVertical: 10, fontSize: 15, fontFamily: 'Nunito_400Regular', color: colors.text },
sendBtn: { width: 38, height: 38, borderRadius: 19, backgroundColor: '#007AFF', alignItems: 'center', justifyContent: 'center' },
});
});
}

View File

@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../stores/auth';
import { useNotificationStore } from '../stores/notifications';
import { resolveAvatar } from '../lib/resolveAvatar';
import { useColors } from '../lib/theme';
import { useMe } from '../hooks/useMe';
import { NotificationsDropdown } from './NotificationsDropdown';
import { HeaderDropdownMenu } from './header/HeaderDropdownMenu';
@ -22,6 +23,7 @@ export function AppHeader({ notifCount, showBack, title }: Props = {}) {
const router = useRouter();
const { t } = useTranslation();
const { user } = useAuthStore();
const colors = useColors();
const { me } = useMe();
const storeUnread = useNotificationStore((s) => s.unread);
const badge = notifCount ?? storeUnread;
@ -47,8 +49,12 @@ export function AppHeader({ notifCount, showBack, title }: Props = {}) {
return (
<View
className="bg-white border-b border-neutral-200"
style={{ paddingTop: insets.top }}
style={{
paddingTop: insets.top,
backgroundColor: colors.surface,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}
>
<View className="h-14 flex-row items-center justify-between px-5">
<View className="flex-row items-center" style={{ gap: 8 }}>
@ -56,14 +62,21 @@ export function AppHeader({ notifCount, showBack, title }: Props = {}) {
<Pressable
onPress={() => router.back()}
hitSlop={10}
className="w-9 h-9 rounded-full items-center justify-center"
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1, marginLeft: -8 })}
style={({ pressed }) => ({
opacity: pressed ? 0.6 : 1,
marginLeft: -8,
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
})}
accessibilityLabel="Zurück"
>
<Ionicons name="chevron-back" size={22} color="#0a0a0a" />
<Ionicons name="chevron-back" size={22} color={colors.text} />
</Pressable>
) : null}
<Text className="text-lg text-midnight-900 tracking-tight" style={{ fontFamily: 'Nunito_700Bold' }}>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 18, color: colors.text, letterSpacing: -0.3 }}>
{title ?? t('appHeader.appName')}
</Text>
</View>
@ -72,10 +85,17 @@ export function AppHeader({ notifCount, showBack, title }: Props = {}) {
<Pressable
onPress={() => setNotifOpen(true)}
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
className="w-9 h-9 rounded-full bg-white items-center justify-center"
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
style={({ pressed }) => ({
opacity: pressed ? 0.7 : 1,
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.surface,
alignItems: 'center',
justifyContent: 'center',
})}
>
<Ionicons name="notifications-outline" size={22} color="#737373" />
<Ionicons name="notifications-outline" size={22} color={colors.textMuted} />
{badge > 0 && (
<View className="absolute top-0 right-0 w-4 h-4 rounded-full bg-rebreak-500 items-center justify-center">
<Text className="text-white text-[9px]" style={{ fontFamily: 'Nunito_700Bold' }}>
@ -89,8 +109,16 @@ export function AppHeader({ notifCount, showBack, title }: Props = {}) {
<Pressable
onPress={() => setMenuOpen(true)}
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
className={`w-9 h-9 rounded-full items-center justify-center overflow-hidden ${showAvatarImage ? 'bg-neutral-100' : 'bg-rebreak-500'}`}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
style={({ pressed }) => ({
opacity: pressed ? 0.7 : 1,
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
backgroundColor: showAvatarImage ? colors.surfaceElevated : colors.brandOrange,
})}
>
{showAvatarImage ? (
<Image

View File

@ -11,12 +11,13 @@ import {
import { Ionicons } from '@expo/vector-icons';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import * as FileSystem from 'expo-file-system';
// TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14
import * as FileSystem from 'expo-file-system/legacy';
import * as ImagePicker from 'expo-image-picker';
import { apiFetch } from '../lib/api';
import { resolveAvatar } from '../lib/resolveAvatar';
import { useAuthStore } from '../stores/auth';
import { colors } from '../lib/theme';
import { useColors } from '../lib/theme';
type Props = {
onPosted?: () => void;
@ -24,6 +25,7 @@ type Props = {
export function ComposeCard({ onPosted }: Props) {
const { t } = useTranslation();
const colors = useColors();
const { user } = useAuthStore();
const queryClient = useQueryClient();
const inputRef = useRef<TextInput>(null);
@ -100,7 +102,7 @@ export function ComposeCard({ onPosted }: Props) {
const showActions = focused || content.length > 0;
return (
<View className="bg-white border border-neutral-200 rounded-2xl p-4 mb-4">
<View style={{ backgroundColor: colors.bg, borderWidth: 1, borderColor: colors.border, borderRadius: 16, padding: 16, marginBottom: 16 }}>
<View className="flex-row items-start gap-3">
<Image
source={{ uri: avatarUrl }}
@ -113,10 +115,10 @@ export function ComposeCard({ onPosted }: Props) {
onChangeText={setContent}
onFocus={() => setFocused(true)}
placeholder={t('community.compose_placeholder')}
placeholderTextColor="#a3a3a3"
placeholderTextColor={colors.textMuted}
multiline
className="text-sm text-neutral-900 leading-5 min-h-[40px]"
style={{ textAlignVertical: 'top', fontFamily: 'Nunito_400Regular' }}
className="text-sm leading-5 min-h-[40px]"
style={{ textAlignVertical: 'top', fontFamily: 'Nunito_400Regular', color: colors.text }}
/>
{imageUri && (
<View className="relative mt-2">

View File

@ -0,0 +1,238 @@
import { ActivityIndicator, Platform, Pressable, Text, View } from 'react-native';
import { useEffect, useRef, useState } from 'react';
import { TrueSheet, type SheetDetent } from '@lodev09/react-native-true-sheet';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useColors } from '../lib/theme';
import { apiFetch } from '../lib/api';
import { useDeviceLimitStore, type DeviceLimitDevice } from '../stores/deviceLimit';
function platformIcon(
platform: string
): React.ComponentProps<typeof Ionicons>['name'] {
if (platform === 'ios') return 'logo-apple';
if (platform === 'android') return 'logo-android';
return 'phone-portrait-outline';
}
function formatLastSeen(iso: string, t: (k: string, o?: any) => string): string {
const ms = Date.now() - new Date(iso).getTime();
const min = Math.floor(ms / 60_000);
if (min < 1) return t('settings.devices_just_now');
if (min < 60) return t('settings.devices_mins_ago', { count: min });
const hr = Math.floor(min / 60);
if (hr < 24) return t('settings.devices_hours_ago', { count: hr });
const day = Math.floor(hr / 24);
if (day < 30) return t('settings.devices_days_ago', { count: day });
return new Date(iso).toLocaleDateString(
Platform.OS === 'ios' ? undefined : 'de-DE',
{ day: '2-digit', month: 'short', year: 'numeric' }
);
}
function DeviceLimitRow({
device,
removing,
onRemove,
}: {
device: DeviceLimitDevice;
removing: boolean;
onRemove: (id: string) => void;
}) {
const { t } = useTranslation();
const colors = useColors();
return (
<View
style={{
flexDirection: 'row',
alignItems: 'flex-start',
gap: 12,
paddingHorizontal: 14,
paddingVertical: 14,
}}
>
<View
style={{
width: 40,
height: 40,
borderRadius: 12,
backgroundColor: 'rgba(0,0,0,0.04)',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={platformIcon(device.platform)} size={20} color={colors.text} />
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text
numberOfLines={1}
style={{
fontSize: 15,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
flexShrink: 1,
}}
>
{device.name ?? device.model ?? device.platform}
</Text>
{device.model && device.name && !device.name.includes(device.model) ? (
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 1,
}}
>
{device.model}
</Text>
) : null}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 }}>
<Ionicons name="time-outline" size={11} color={colors.textMuted} />
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{formatLastSeen(device.lastSeenAt, t)}
</Text>
</View>
</View>
{removing ? (
<ActivityIndicator size="small" color={colors.error} />
) : (
<Pressable
onPress={() => onRemove(device.id)}
hitSlop={8}
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
>
<Ionicons name="trash-outline" size={18} color={colors.error} />
</Pressable>
)}
</View>
);
}
export function DeviceLimitReachedSheet() {
const { t } = useTranslation();
const colors = useColors();
const sheetRef = useRef<TrueSheet>(null);
const { visible, devices, max, plan, hide, removeDevice } = useDeviceLimitStore();
const [removingId, setRemovingId] = useState<string | null>(null);
useEffect(() => {
if (visible) {
sheetRef.current?.present();
}
}, [visible]);
async function handleRemove(id: string) {
setRemovingId(id);
try {
await apiFetch(`/api/devices/${id}`, {
method: 'DELETE',
skipDeviceHeader: true,
});
removeDevice(id);
const remaining = useDeviceLimitStore.getState().devices;
if (remaining.length < max) {
sheetRef.current?.dismiss();
hide();
}
} finally {
setRemovingId(null);
}
}
return (
<TrueSheet
ref={sheetRef}
detents={['auto', 1] satisfies SheetDetent[]}
cornerRadius={20}
grabber
onDidDismiss={hide}
>
<View style={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 32 }}>
<View
style={{
width: 40,
height: 40,
borderRadius: 12,
backgroundColor: 'rgba(239,68,68,0.1)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 12,
}}
>
<Ionicons name="phone-portrait-outline" size={20} color={colors.error} />
</View>
<Text
style={{
fontSize: 22,
color: colors.text,
fontFamily: 'Nunito_700Bold',
marginBottom: 6,
}}
>
{t('device_limit.title')}
</Text>
<Text
style={{
fontSize: 14,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginBottom: 20,
lineHeight: 20,
}}
>
{t('device_limit.subtitle', { count: devices.length, max, plan: plan.toUpperCase() })}
</Text>
<View
style={{
backgroundColor: colors.bg,
borderRadius: 14,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(239,68,68,0.12)',
}}
>
{devices.map((device, i) => (
<View
key={device.id}
style={{
borderBottomWidth: i < devices.length - 1 ? 1 : 0,
borderBottomColor: 'rgba(0,0,0,0.04)',
}}
>
<DeviceLimitRow
device={device}
removing={removingId === device.id}
onRemove={handleRemove}
/>
</View>
))}
</View>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 12,
lineHeight: 16,
}}
>
{t('device_limit.hint')}
</Text>
</View>
</TrueSheet>
);
}

View File

@ -0,0 +1,67 @@
import { ReactNode } from 'react';
import { Platform, ScrollView, StyleProp, ViewStyle } from 'react-native';
import { useKeyboardHeight } from '../hooks/useKeyboardHeight';
/**
* Universal-Wrapper für Forms/Pages mit TextInput.
*
* Für Vollbild-Formulare (Auth, Profile-Edit) reicht das alleine:
* - iOS: `automaticallyAdjustKeyboardInsets` (iOS 14+) verschiebt focused Input aktiv.
* - Android: `paddingBottom: keyboardHeight` + `windowSoftInputMode=adjustResize`
* im Manifest.
*
* Für FIXED-HEIGHT Sheets/Modals reicht das nicht der Sheet selbst muss
* zusätzlich nach oben verschoben werden. Pattern:
* ```tsx
* const keyboardHeight = useKeyboardHeight();
* <Animated.View style={{
* transform: [{ translateY: ... }],
* marginBottom: keyboardHeight, // lift sheet above keyboard
* }}>
* <KeyboardAdjustedView>
* {form content}
* </KeyboardAdjustedView>
* </Animated.View>
* ```
*
* Siehe `EditMailAccountSheet.tsx` für vollständiges Sheet-Pattern.
*
* Anti-Pattern: KeyboardAvoidingView mit `behavior="padding"` greift bei
* Vollbild-Layouts mit `paddingTop: insets.top` nicht siehe
* `docs/internal/RECOVERY_LOG_2026-05-10.md` §7.2.
*/
export interface KeyboardAdjustedViewProps {
children: ReactNode;
/** Style für den ScrollView (outer container). */
style?: StyleProp<ViewStyle>;
/** Style für den ScrollView-Inhalt (Padding gehört hier rein, nicht in `style`). */
contentContainerStyle?: StyleProp<ViewStyle>;
/** Extra Padding bottom on top of keyboard height (z.B. wenn fixed CTA-Bar drüber sitzt). */
extraBottomOffset?: number;
/** Default 'handled' — Tap auf nicht-Input-Bereich schließt Keyboard. */
keyboardShouldPersistTaps?: 'always' | 'never' | 'handled';
}
export function KeyboardAdjustedView({
children,
style,
contentContainerStyle,
extraBottomOffset = 0,
keyboardShouldPersistTaps = 'handled',
}: KeyboardAdjustedViewProps) {
const keyboardHeight = useKeyboardHeight();
const bottomPad = keyboardHeight > 0 ? keyboardHeight + extraBottomOffset : 0;
return (
<ScrollView
style={style}
contentContainerStyle={[contentContainerStyle, { paddingBottom: bottomPad }]}
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
keyboardDismissMode={Platform.OS === 'ios' ? 'interactive' : 'on-drag'}
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
);
}

View File

@ -0,0 +1,222 @@
import { ReactNode, useEffect, useRef, useState } from 'react';
import {
Animated,
Easing,
Keyboard,
Modal,
Platform,
Pressable,
StyleProp,
View,
ViewStyle,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useColors } from '../lib/theme';
/**
* Universal-Bottom-Sheet für Forms mit TextInput.
*
* Pattern (verifiziert auf PostCommentsSheet + EditMailAccountSheet):
*
* 1. Outer-Animated.View hat animated `height` (JS-driver) Sheet WÄCHST
* bei Tastatur-Open um genau die Tastatur-Höhe.
* 2. Inner-Animated.View hat `transform: translateY` (Native-driver)
* Slide-In/Out smooth. Driver-Mix-Trennung verhindert
* "Style property 'height' is not supported by native animated module"-Crash.
* 3. iOS: `paddingBottom: keyboardHeight` shifted Form innerhalb des
* gewachsenen Sheets über die Tastatur. Android: `windowSoftInputMode=adjustResize`
* im Manifest schrumpft das Window selbst.
* 4. Flex-Spacer drückt `children` (Form) automatisch an den Sheet-Bottom-Edge
* sitzt direkt über der Tastatur ohne Gap.
*
* Anti-Pattern (siehe `docs/internal/RECOVERY_LOG_2026-05-10.md` §7.2):
* - `useKeyboardAnimation()` aus `react-native-keyboard-controller` liefert
* in iOS-Modals keine Höhe (separate UIWindow). Hier: plain RN
* `Keyboard.addListener` für die Höhe.
* - `Animated.subtract`/`marginBottom: keyboardHeight` mischen JS+Native-Driver
* auf demselben View Bouncing oder Crash.
*
* Usage:
* ```tsx
* <KeyboardAwareSheet
* visible={visible}
* onClose={onClose}
* collapsedHeight={280}
* header={<HeaderRow title="Passwort" onCancel={onClose} />}
* >
* <View style={{ padding: 20, gap: 14 }}>
* <TextInput ... />
* <SaveButton ... />
* </View>
* </KeyboardAwareSheet>
* ```
*/
export interface KeyboardAwareSheetProps {
visible: boolean;
onClose: () => void;
/** Sheet-Höhe wenn Tastatur zu. Eng auf Inhalt zuschneiden — typisch 220-340px. */
collapsedHeight: number;
/** Optionaler Header (Cancel/Title-Row). Rendert direkt unter dem Drag-Handle. */
header?: ReactNode;
/** Form-Inhalt. Wird per Flex-Spacer an den Sheet-Bottom gedrückt sitzt
* damit direkt über der Tastatur sobald die offen ist. */
children: ReactNode;
/** Default true — Tap auf Backdrop schließt das Sheet. */
dismissOnBackdrop?: boolean;
/** Default true — kleiner Drag-Handle ganz oben am Sheet. */
showDragHandle?: boolean;
/** Default true — fügt unten eine Safe-Area-Spacer-Höhe ein (insets.bottom). */
showSafeAreaSpacer?: boolean;
/** Default true interner Flex-Spacer drückt children zum Sheet-Bottom.
* Auf false setzen wenn der Inhalt seine eigene Scroll-/Flex-Logik hat
* (z.B. ScrollView mit Provider-Grid, Listen). */
pushChildrenToBottom?: boolean;
/** Border-Radius oben. Default 20. */
topRadius?: number;
/** Optional zusätzlicher Style für den Sheet-Container. */
containerStyle?: StyleProp<ViewStyle>;
}
export function KeyboardAwareSheet({
visible,
onClose,
collapsedHeight,
header,
children,
dismissOnBackdrop = true,
showDragHandle = true,
showSafeAreaSpacer = true,
pushChildrenToBottom = true,
topRadius = 20,
containerStyle,
}: KeyboardAwareSheetProps) {
const colors = useColors();
const insets = useSafeAreaInsets();
const slideY = useRef(new Animated.Value(collapsedHeight)).current;
const backdropOpacity = useRef(new Animated.Value(0)).current;
const sheetHeight = useRef(new Animated.Value(collapsedHeight)).current;
const [keyboardHeight, setKeyboardHeight] = useState(0);
// Slide-In + Backdrop-Fade bei `visible=true`
useEffect(() => {
if (visible) {
slideY.setValue(collapsedHeight);
backdropOpacity.setValue(0);
Animated.parallel([
Animated.timing(slideY, {
toValue: 0,
duration: 280,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(backdropOpacity, {
toValue: 1,
duration: 220,
useNativeDriver: true,
}),
]).start();
}
}, [visible, slideY, backdropOpacity, collapsedHeight]);
// Sheet-Höhe wächst/schrumpft mit Tastatur
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const showSub = Keyboard.addListener(showEvent, (e) => {
const h = e.endCoordinates.height;
setKeyboardHeight(h);
Animated.timing(sheetHeight, {
toValue: collapsedHeight + h,
duration: Platform.OS === 'ios' ? (e.duration ?? 250) : 220,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
});
const hideSub = Keyboard.addListener(hideEvent, (e) => {
setKeyboardHeight(0);
Animated.timing(sheetHeight, {
toValue: collapsedHeight,
duration: Platform.OS === 'ios' ? (e?.duration ?? 250) : 220,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
});
return () => {
showSub.remove();
hideSub.remove();
};
}, [sheetHeight, collapsedHeight]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose}>
{/* Backdrop */}
<Animated.View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
opacity: backdropOpacity,
}}
>
{dismissOnBackdrop && <Pressable style={{ flex: 1 }} onPress={onClose} />}
</Animated.View>
{/* Outer: animated height (JS-driver) */}
<Animated.View
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: sheetHeight,
}}
>
{/* Inner: animated transform (Native-driver). Driver-Mix vermeiden
durch zwei verschachtelte Animated.Views. */}
<Animated.View
style={[
{
flex: 1,
backgroundColor: colors.bg,
borderTopLeftRadius: topRadius,
borderTopRightRadius: topRadius,
transform: [{ translateY: slideY }],
paddingBottom: Platform.OS === 'ios' ? keyboardHeight : 0,
},
containerStyle,
]}
>
<View style={{ flex: 1 }}>
{showDragHandle && (
<View style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 4 }}>
<View
style={{
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: colors.border,
}}
/>
</View>
)}
{header}
{pushChildrenToBottom ? (
<>
{/* Flex-Spacer drückt children an den Sheet-Bottom */}
<View style={{ flex: 1 }} />
{children}
</>
) : (
<View style={{ flex: 1 }}>{children}</View>
)}
{showSafeAreaSpacer && <View style={{ height: insets.bottom }} />}
</View>
</Animated.View>
</Animated.View>
</Modal>
);
}

View File

@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useNotificationStore, type AppNotification } from '../stores/notifications';
import { resolveAvatar } from '../lib/resolveAvatar';
import { HeroShieldCheck } from './HeroShieldCheck';
import { useColors } from '../lib/theme';
type Props = {
visible: boolean;
@ -16,6 +17,7 @@ type Props = {
export function NotificationsDropdown({ visible, onClose, topOffset }: Props) {
const { t } = useTranslation();
const colors = useColors();
const router = useRouter();
const items = useNotificationStore((s) => s.items);
const loaded = useNotificationStore((s) => s.loaded);
@ -71,7 +73,7 @@ export function NotificationsDropdown({ visible, onClose, topOffset }: Props) {
position: 'absolute',
top: topOffset + 6,
right: 12,
backgroundColor: '#ffffff',
backgroundColor: colors.bg,
borderRadius: 18,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
@ -93,11 +95,11 @@ export function NotificationsDropdown({ visible, onClose, topOffset }: Props) {
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
borderBottomColor: colors.border,
}}
>
<Text
style={{ flex: 1, fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}
style={{ flex: 1, fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}
>
{t('notifications.title')}
</Text>
@ -114,13 +116,13 @@ export function NotificationsDropdown({ visible, onClose, topOffset }: Props) {
{items.length === 0 ? (
<View style={{ paddingVertical: 32, alignItems: 'center' }}>
<Ionicons name="notifications-off-outline" size={28} color="#a3a3a3" />
<Ionicons name="notifications-off-outline" size={28} color={colors.textMuted} />
<Text
style={{
marginTop: 8,
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: '#525252',
color: colors.textMuted,
}}
>
{t('notifications.empty_title')}
@ -130,7 +132,7 @@ export function NotificationsDropdown({ visible, onClose, topOffset }: Props) {
marginTop: 2,
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: '#a3a3a3',
color: colors.textMuted,
textAlign: 'center',
paddingHorizontal: 20,
}}
@ -221,6 +223,7 @@ function NotificationRow({
onPress: () => void;
t: (k: string, opts?: any) => string;
}) {
const colors = useColors();
const isUnread = !notif.readAt;
const { icon, color, bg } = notifIcon(notif.type);
const isSocial =
@ -249,8 +252,8 @@ function NotificationRow({
paddingHorizontal: 14,
paddingVertical: 11,
borderBottomWidth: 1,
borderBottomColor: '#f5f5f5',
backgroundColor: isUnread ? '#fff7ed' : '#ffffff',
borderBottomColor: colors.border,
backgroundColor: isUnread ? colors.surface : colors.bg,
}}
>
{/* Avatar-Logik:
@ -297,7 +300,7 @@ function NotificationRow({
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#0a0a0a',
color: colors.text,
lineHeight: 16,
}}
numberOfLines={2}
@ -308,7 +311,7 @@ function NotificationRow({
style={{
fontSize: 10,
fontFamily: 'Nunito_400Regular',
color: '#a3a3a3',
color: colors.textMuted,
marginTop: 2,
}}
>

View File

@ -22,7 +22,7 @@ import {
Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors } from '../lib/theme';
import { useColors } from '../lib/theme';
type Option<T> = {
value: T;
@ -51,6 +51,7 @@ export function OptionsBottomSheet<T extends string | number>({
onClose,
}: Props<T>) {
const insets = useSafeAreaInsets();
const colors = useColors();
const translateY = useRef(new Animated.Value(400)).current;
const backdropOpacity = useRef(new Animated.Value(0)).current;

View File

@ -9,6 +9,7 @@ import { formatRelativeTime } from '../lib/formatTime';
import { useCommunityStore, type CommunityPost } from '../stores/community';
import { RiveAvatar } from './RiveAvatar';
import { HeroShieldCheck } from './HeroShieldCheck';
import { useColors } from '../lib/theme';
type Props = {
post: CommunityPost;
@ -17,6 +18,7 @@ type Props = {
function PostCardImpl({ post, onCommentPress }: Props) {
const { t } = useTranslation();
const colors = useColors();
const queryClient = useQueryClient();
// Granular selectors — subscribing to the whole store would re-render every
// PostCard whenever any user likes any post (optimisticLikes mutates).
@ -162,7 +164,7 @@ function PostCardImpl({ post, onCommentPress }: Props) {
}, [isLiking, localLike, localCount, post.id, post.userLike, post.likesCount, applyOptimisticLike, clearOptimisticLike, revertOptimisticLike, queryClient, triggerHeartPop]);
return (
<View className="bg-white border border-neutral-200 rounded-2xl p-3 mb-3">
<View style={{ backgroundColor: colors.bg, borderWidth: 1, borderColor: colors.border, borderRadius: 16, padding: 12, marginBottom: 12 }}>
{/* Repost header */}
{post.repostOf && (
<View className="flex-row items-center gap-1.5 mb-3">
@ -194,35 +196,35 @@ function PostCardImpl({ post, onCommentPress }: Props) {
</View>
)}
<View className="flex-1 min-w-0">
<Text className="text-sm text-neutral-900" numberOfLines={1} style={{ fontFamily: 'Nunito_600SemiBold' }}>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_600SemiBold' }} numberOfLines={1}>
{authorLabel}
</Text>
{authorDescription !== undefined && (
<Text className="text-xs text-neutral-400" style={{ fontFamily: 'Nunito_400Regular' }}>{authorDescription}</Text>
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>{authorDescription}</Text>
)}
</View>
</View>
<Text className="text-xs text-neutral-400 shrink-0 ml-2 mt-0.5" style={{ fontFamily: 'Nunito_400Regular' }}>
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular', flexShrink: 0, marginLeft: 8, marginTop: 2 }}>
{formatRelativeTime(post.createdAt)}
</Text>
</View>
{/* Content — hidden for domain_vote (replaced by poll below) */}
{!!displayContent && post.category !== 'domain_vote' && (
<Text className="text-sm text-neutral-800 leading-relaxed mb-0" style={{ fontFamily: 'Nunito_400Regular' }}>
<Text style={{ fontSize: 14, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 21 }}>
{displayContent}
</Text>
)}
{/* domain_approved: favicon + domain name + shield badge */}
{post.category === 'domain_approved' && !!approvedDomain && (
<View className="flex-row items-center gap-2.5 mt-3 rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2">
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 12, borderRadius: 12, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface, paddingHorizontal: 12, paddingVertical: 8 }}>
<DomainFavicon domain={approvedDomain} size={24} />
<View className="flex-1 min-w-0">
<Text className="text-xs font-semibold text-neutral-900 truncate" style={{ fontFamily: 'Nunito_700Bold' }}>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={{ fontSize: 12, color: colors.text, fontFamily: 'Nunito_700Bold' }} numberOfLines={1}>
{approvedDomain}
</Text>
<Text className="text-xs text-neutral-400" style={{ fontFamily: 'Nunito_400Regular' }}>
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
{t('community.domain_added_to_blocklist')}
</Text>
</View>

View File

@ -1,18 +1,20 @@
import { View } from 'react-native';
import { useColors } from '../lib/theme';
export function PostCardSkeleton() {
const colors = useColors();
return (
<View className="bg-white border border-neutral-200 rounded-2xl p-4 mb-3">
<View className="flex-row items-center gap-3 mb-3">
<View className="w-9 h-9 rounded-full bg-neutral-200" />
<View className="flex-1 gap-1.5">
<View className="h-3 bg-neutral-200 rounded w-1/3" />
<View className="h-2.5 bg-neutral-100 rounded w-1/4" />
<View style={{ backgroundColor: colors.bg, borderWidth: 1, borderColor: colors.border, borderRadius: 16, padding: 16, marginBottom: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<View style={{ width: 36, height: 36, borderRadius: 18, backgroundColor: colors.surfaceElevated }} />
<View style={{ flex: 1, gap: 6 }}>
<View style={{ height: 12, backgroundColor: colors.surfaceElevated, borderRadius: 6, width: '33%' }} />
<View style={{ height: 10, backgroundColor: colors.surface, borderRadius: 6, width: '25%' }} />
</View>
</View>
<View className="h-3 bg-neutral-200 rounded w-full mb-2" />
<View className="h-3 bg-neutral-200 rounded w-3/4 mb-2" />
<View className="h-3 bg-neutral-100 rounded w-1/2" />
<View style={{ height: 12, backgroundColor: colors.surfaceElevated, borderRadius: 6, width: '100%', marginBottom: 8 }} />
<View style={{ height: 12, backgroundColor: colors.surfaceElevated, borderRadius: 6, width: '75%', marginBottom: 8 }} />
<View style={{ height: 12, backgroundColor: colors.surface, borderRadius: 6, width: '50%' }} />
</View>
);
}

View File

@ -19,7 +19,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '../lib/api';
import { formatRelativeTime } from '../lib/formatTime';
import { colors } from '../lib/theme';
import { useColors } from '../lib/theme';
import type { CommunityComment } from '../stores/community';
const EMOJIS = ['❤️', '🙌', '🔥', '👏', '😢', '😍', '😮', '😂'];
@ -33,6 +33,7 @@ type Props = {
export function PostCommentsSheet({ postId, visible, onClose }: Props) {
const { t } = useTranslation();
const colors = useColors();
const insets = useSafeAreaInsets();
const queryClient = useQueryClient();
const inputRef = useRef<TextInput>(null);
@ -230,7 +231,7 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
<Animated.View
style={{
flex: 1,
backgroundColor: '#ffffff',
backgroundColor: colors.bg,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: 'hidden',
@ -255,7 +256,7 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
width: 36,
height: 5,
borderRadius: 3,
backgroundColor: '#d4d4d8',
backgroundColor: colors.border,
}}
/>
</View>
@ -268,10 +269,10 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
paddingTop: 6,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: '#e5e5e5',
borderBottomColor: colors.border,
}}
>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('community.comments_title')}
</Text>
</View>
@ -323,7 +324,7 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
paddingHorizontal: 16,
paddingVertical: 8,
borderTopWidth: 1,
borderTopColor: '#f5f5f5',
borderTopColor: colors.border,
}}
>
{EMOJIS.map((e) => (
@ -342,10 +343,10 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: '#fafafa',
backgroundColor: colors.surface,
}}
>
<Text style={{ fontSize: 12, color: '#737373', fontFamily: 'Nunito_400Regular' }}>
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
{t('community.reply_to')}{' '}
<Text style={{ fontFamily: 'Nunito_700Bold' }}>@{replyTarget.nickname}</Text>
</Text>
@ -366,7 +367,7 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
// sonst Safe-Area
paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom),
borderTopWidth: 1,
borderTopColor: '#e5e5e5',
borderTopColor: colors.border,
}}
>
<TextInput
@ -374,18 +375,18 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
value={text}
onChangeText={setText}
placeholder={t('community.comment_placeholder')}
placeholderTextColor="#a3a3a3"
placeholderTextColor={colors.textMuted}
style={{
flex: 1,
backgroundColor: '#fafafa',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: '#e5e5e5',
borderColor: colors.border,
borderRadius: 999,
paddingHorizontal: 16,
paddingVertical: 10,
fontSize: 14,
fontFamily: 'Nunito_400Regular',
color: '#0a0a0a',
color: colors.text,
marginRight: 8,
}}
returnKeyType="send"
@ -430,6 +431,7 @@ type CommentRowProps = {
function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowProps) {
const { t } = useTranslation();
const colors = useColors();
const heartScale = useRef(new Animated.Value(1)).current;
const handleLikeWithPop = useCallback(() => {
heartScale.setValue(1);
@ -447,26 +449,26 @@ function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowPro
width: isReply ? 24 : 32,
height: isReply ? 24 : 32,
borderRadius: isReply ? 12 : 16,
backgroundColor: '#e5e5e5',
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
marginTop: 2,
}}
>
<Text style={{ fontSize: isReply ? 9 : 11, fontFamily: 'Nunito_700Bold', color: '#737373' }}>
<Text style={{ fontSize: isReply ? 9 : 11, fontFamily: 'Nunito_700Bold', color: colors.textMuted }}>
{(comment.authorNickname ?? 'AN').slice(0, 2).toUpperCase()}
</Text>
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{comment.authorNickname ?? t('community.anonymous_label')}
</Text>
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_400Regular',
color: '#404040',
color: colors.textMuted,
lineHeight: 20,
marginTop: 2,
}}
@ -474,12 +476,12 @@ function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowPro
{comment.content}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16, marginTop: 6 }}>
<Text style={{ fontSize: 10, color: '#a3a3a3', fontFamily: 'Nunito_400Regular' }}>
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
{formatRelativeTime(comment.createdAt)}
</Text>
{!isReply && onReply && (
<Pressable onPress={onReply}>
<Text style={{ fontSize: 11, color: '#a3a3a3', fontFamily: 'Nunito_600SemiBold' }}>
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
{t('community.reply')}
</Text>
</Pressable>

View File

@ -39,18 +39,21 @@ function preloadRiveAsset(): Promise<string | null> {
// Render bereits die cached URI nutzt (außer im allerersten App-Start).
preloadRiveAsset();
export type Emotion = 'idle' | 'happy' | 'thinking' | 'empathy';
// Supported emotions sind durch state-machine im .riv-file definiert.
// Neue states: nur EMOTION_ANIMATIONS + EMOTION_LABELS erweitern, kein weiterer Code-Change nötig.
export type SupportedEmotion = 'idle' | 'happy' | 'thinking' | 'empathy';
export type Emotion = SupportedEmotion | (string & {});
// Direkte Animation-Namen aus der .riv-Datei (1:1 mit Nuxt BenAvatar.vue).
// "happy" hat zwei Phasen: erst Übergangs-Animation, dann Loop.
const EMOTION_ANIMATIONS: Record<Emotion, string> = {
const EMOTION_ANIMATIONS: Record<string, string> = {
idle: 'Idle Loop',
happy: 'idle to Pose 1',
thinking: 'WALK',
empathy: '01 Wave 1',
};
const EMOTION_LABELS: Record<Emotion, string> = {
const EMOTION_LABELS: Record<string, string> = {
idle: 'bereit',
happy: 'froh für dich',
thinking: 'überlegt ...',
@ -67,13 +70,16 @@ type Props = {
emotion: Emotion;
size?: 'sm' | 'md' | 'lg';
showLabel?: boolean;
fallback?: Emotion;
};
export function RiveAvatar({ emotion, size = 'md', showLabel = false }: Props) {
export function RiveAvatar({ emotion, size = 'md', showLabel = false, fallback = 'idle' }: Props) {
const px = SIZE_PX[size];
const resolvedEmotion = EMOTION_ANIMATIONS[emotion] !== undefined ? emotion : fallback;
// Aktuelle Animation als deklarativer State (kein imperatives ref.play()).
const [currentAnim, setCurrentAnim] = useState<string>(EMOTION_ANIMATIONS.idle);
const [currentAnim, setCurrentAnim] = useState<string>(EMOTION_ANIMATIONS[resolvedEmotion] ?? EMOTION_ANIMATIONS.idle);
// Lokale URI für die .riv-Datei — geht über expo-asset damit der File
// im App-Sandbox gecached wird statt jedes Mal von Metro zu streamen.
@ -92,14 +98,14 @@ export function RiveAvatar({ emotion, size = 'md', showLabel = false }: Props) {
}, [riveUri]);
useEffect(() => {
if (emotion === 'happy') {
if (resolvedEmotion === 'happy') {
// 2-Phasen-Flow: Übergang (~900ms) → Loop (1:1 wie Nuxt-BenAvatar)
setCurrentAnim('idle to Pose 1');
const t = setTimeout(() => setCurrentAnim('Pose 1 loop'), 900);
return () => clearTimeout(t);
}
setCurrentAnim(EMOTION_ANIMATIONS[emotion]);
}, [emotion]);
setCurrentAnim(EMOTION_ANIMATIONS[resolvedEmotion] ?? EMOTION_ANIMATIONS.idle);
}, [resolvedEmotion]);
return (
<View style={{ alignItems: 'center', gap: 4 }}>
@ -158,7 +164,7 @@ export function RiveAvatar({ emotion, size = 'md', showLabel = false }: Props) {
{showLabel && (
<Text style={{ fontSize: 11, color: '#737373', letterSpacing: 0.3 }}>
{EMOTION_LABELS[emotion]}
{EMOTION_LABELS[resolvedEmotion] ?? EMOTION_LABELS[fallback] ?? ''}
</Text>
)}
</View>

View File

@ -1,7 +1,7 @@
import { View, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { colors } from '../lib/theme';
import { useColors } from '../lib/theme';
type Props = {
days: number;
@ -16,14 +16,18 @@ const sizeMap = {
export function StreakBadge({ days, size = 'md' }: Props) {
const { t } = useTranslation();
const colors = useColors();
const s = sizeMap[size];
return (
<View className={`items-center bg-white border border-neutral-200 rounded-3xl ${s.padding}`}>
<View
className={`items-center rounded-3xl ${s.padding}`}
style={{ backgroundColor: colors.bg, borderWidth: 1, borderColor: colors.border }}
>
<View className="flex-row items-center gap-2 mb-1">
<Ionicons name="flame" size={s.icon} color={colors.brandOrange} />
<Text className={`${s.number} text-neutral-900 tabular-nums`} style={{ fontFamily: 'Nunito_800ExtraBold' }}>{days}</Text>
<Text className={`${s.number} tabular-nums`} style={{ fontFamily: 'Nunito_800ExtraBold', color: colors.text }}>{days}</Text>
</View>
<Text className={`${s.label} text-neutral-500 tracking-wide uppercase`} style={{ fontFamily: 'Nunito_600SemiBold' }}>
<Text className={`${s.label} tracking-wide uppercase`} style={{ fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
{days === 1 ? t('streak.label_one') : t('streak.label_other')} {t('streak.label_suffix')}
</Text>
</View>

View File

@ -14,7 +14,7 @@
import { useEffect, useState } from 'react';
import { Modal, View, Text, Pressable } from 'react-native';
import { Picker } from '@react-native-picker/picker';
import { colors } from '../lib/theme';
import { useColors } from '../lib/theme';
type Option<T> = { value: T; label: string };
@ -35,6 +35,7 @@ export function WheelPickerModal<T extends string | number>({
onSelect,
onClose,
}: Props<T>) {
const colors = useColors();
// Tracks the wheel's current selection (separate from confirmed value).
// Initialized from `value` prop on each open.
const [tempValue, setTempValue] = useState<T | null>(value);
@ -64,14 +65,15 @@ export function WheelPickerModal<T extends string | number>({
onPress={onClose}
style={{
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
// Kein darkening (User-Regel) — backdrop nur als Tap-to-close-Layer
backgroundColor: 'transparent',
justifyContent: 'flex-end',
}}
>
<Pressable onPress={() => {}}>
<View
style={{
backgroundColor: '#ffffff',
backgroundColor: colors.bg,
borderTopLeftRadius: 18,
borderTopRightRadius: 18,
paddingBottom: 24,
@ -86,7 +88,7 @@ export function WheelPickerModal<T extends string | number>({
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#e5e5e5',
borderBottomColor: colors.border,
}}
>
<Pressable onPress={onClose} hitSlop={10}>

View File

@ -1,17 +1,11 @@
import { useState, useEffect, useRef } from 'react';
import { useState } from 'react';
import {
Modal,
View,
Text,
TextInput,
Pressable,
KeyboardAvoidingView,
Platform,
Image,
ActivityIndicator,
Animated,
Dimensions,
Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
@ -21,9 +15,10 @@ import {
normalizeDomain,
type Tier,
} from '../../hooks/useCustomDomains';
import { useColors } from '../../lib/theme';
import { KeyboardAwareSheet } from '../KeyboardAwareSheet';
const SCREEN_HEIGHT = Dimensions.get('window').height;
const SHEET_HEIGHT = SCREEN_HEIGHT * 0.65; // wie bei PostCommentsSheet — 65% der Screen-Höhe
const COLLAPSED_HEIGHT = 600;
type Props = {
visible: boolean;
@ -34,6 +29,7 @@ type Props = {
export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
const { t } = useTranslation();
const colors = useColors();
const insets = useSafeAreaInsets();
const [input, setInput] = useState('');
const [confirmPermanent, setConfirmPermanent] = useState(false);
@ -43,30 +39,6 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
const valid = isValidDomain(input);
const normalized = normalizeDomain(input);
// Slide-up Animation für die Sheet (translateY von SHEET_HEIGHT → 0)
const translateY = useRef(new Animated.Value(SHEET_HEIGHT)).current;
const backdropOpacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
translateY.setValue(SHEET_HEIGHT);
backdropOpacity.setValue(0);
Animated.parallel([
Animated.timing(translateY, {
toValue: 0,
duration: 280,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(backdropOpacity, {
toValue: 1,
duration: 220,
useNativeDriver: true,
}),
]).start();
}
}, [visible, translateY, backdropOpacity]);
function close() {
setInput('');
setConfirmPermanent(false);
@ -96,48 +68,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
? t('blocker.add_sheet_warning_free')
: t('blocker.add_sheet_warning_pro');
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={close}>
{/* Backdrop — Tap-outside schließt */}
<Animated.View
style={{
position: 'absolute',
inset: 0 as any,
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
opacity: backdropOpacity,
}}
>
<Pressable style={{ flex: 1 }} onPress={close} />
</Animated.View>
{/* Sheet — slide-up von unten, 65% der Screen-Höhe */}
<Animated.View
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: SHEET_HEIGHT,
backgroundColor: '#fff',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
transform: [{ translateY }],
}}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={{ flex: 1 }}
>
{/* Drag-handle */}
<View style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 4 }}>
<View style={{ width: 36, height: 4, borderRadius: 2, backgroundColor: '#d4d4d4' }} />
</View>
{/* Header */}
const header = (
<View
style={{
flexDirection: 'row',
@ -147,20 +78,29 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
paddingTop: 6,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
borderBottomColor: colors.border,
}}
>
<Pressable onPress={close} hitSlop={10}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('common.cancel')}
</Text>
</Pressable>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('blocker.add_sheet_title')}
</Text>
<View style={{ width: 60 }} />
</View>
);
return (
<KeyboardAwareSheet
visible={visible}
onClose={close}
collapsedHeight={COLLAPSED_HEIGHT}
header={header}
pushChildrenToBottom={false}
>
<View style={{ flex: 1, padding: 20, gap: 14 }}>
{/* Input */}
<View>
@ -168,7 +108,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#525252',
color: colors.textMuted,
marginBottom: 6,
}}
>
@ -181,7 +121,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
setError(null);
}}
placeholder={t('blocker.add_sheet_placeholder')}
placeholderTextColor="#a3a3a3"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
autoFocus
@ -189,13 +129,13 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
returnKeyType="done"
onSubmitEditing={handleAdd}
style={{
backgroundColor: '#f5f5f5',
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: '#0a0a0a',
color: colors.text,
}}
/>
{input && !valid && (
@ -220,7 +160,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
alignItems: 'center',
gap: 10,
padding: 12,
backgroundColor: '#f5f5f5',
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
}}
>
@ -235,7 +175,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
flex: 1,
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: '#0a0a0a',
color: colors.text,
}}
numberOfLines={1}
>
@ -289,8 +229,8 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
height: 22,
borderRadius: 6,
borderWidth: 1.5,
borderColor: confirmPermanent ? '#16a34a' : '#d4d4d4',
backgroundColor: confirmPermanent ? '#16a34a' : '#fff',
borderColor: confirmPermanent ? colors.success : colors.border,
backgroundColor: confirmPermanent ? colors.success : colors.bg,
alignItems: 'center',
justifyContent: 'center',
marginTop: 1,
@ -303,7 +243,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
flex: 1,
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#0a0a0a',
color: colors.text,
lineHeight: 18,
}}
>
@ -314,9 +254,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
{/* Error */}
{error && (
<Text
style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: '#dc2626' }}
>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: '#dc2626' }}>
{error}
</Text>
)}
@ -332,12 +270,14 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
marginBottom: insets.bottom > 0 ? 8 : 12,
})}
>
<View style={{
<View
style={{
backgroundColor: !valid || !confirmPermanent ? '#d4d4d4' : '#dc2626',
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
}}>
}}
>
{adding ? (
<ActivityIndicator color="#fff" />
) : (
@ -348,8 +288,6 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
</View>
</Pressable>
</View>
</KeyboardAvoidingView>
</Animated.View>
</Modal>
</KeyboardAwareSheet>
);
}

View File

@ -2,6 +2,7 @@ import { View, Text, Pressable, ActivityIndicator } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
type Props = {
remainingFormatted: string; // "23:59:42"
@ -10,6 +11,7 @@ type Props = {
export function CooldownBanner({ remainingFormatted, onCancel }: Props) {
const { t } = useTranslation();
const colors = useColors();
const [cancelling, setCancelling] = useState(false);
async function handleCancel() {

View File

@ -2,6 +2,7 @@ import { Modal, View, Text, Pressable, ScrollView, ActionSheetIOS, Platform, Ale
import { Ionicons } from '@expo/vector-icons';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
type Props = {
visible: boolean;
@ -26,6 +27,7 @@ export function DeactivationExplainerSheet({
onStartCooldown,
}: Props) {
const { t } = useTranslation();
const colors = useColors();
const [submitting, setSubmitting] = useState(false);
function showFinalConfirm() {
@ -74,7 +76,7 @@ export function DeactivationExplainerSheet({
presentationStyle="pageSheet"
onRequestClose={onClose}
>
<View style={{ flex: 1, backgroundColor: '#fff' }}>
<View style={{ flex: 1, backgroundColor: colors.bg }}>
{/* Header */}
<View
style={{
@ -85,29 +87,29 @@ export function DeactivationExplainerSheet({
paddingTop: 14,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
borderBottomColor: colors.border,
}}
>
<Pressable onPress={onClose} hitSlop={10}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('common.back')}
</Text>
</Pressable>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('blocker.deactivation_heading')}
</Text>
<View style={{ width: 50 }} />
</View>
<ScrollView contentContainerStyle={{ padding: 20, gap: 18 }}>
<Text style={{ fontSize: 22, fontFamily: 'Nunito_800ExtraBold', color: '#0a0a0a' }}>
<Text style={{ fontSize: 22, fontFamily: 'Nunito_800ExtraBold', color: colors.text }}>
{t('blocker.deactivation_title')}
</Text>
<Text
style={{
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: '#404040',
color: colors.textMuted,
lineHeight: 22,
}}
>
@ -195,6 +197,7 @@ function BulletRow({
title: string;
text: string;
}) {
const colors = useColors();
return (
<View style={{ flexDirection: 'row', gap: 12 }}>
<View
@ -202,22 +205,22 @@ function BulletRow({
width: 36,
height: 36,
borderRadius: 10,
backgroundColor: '#f5f5f5',
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={icon} size={18} color="#525252" />
<Ionicons name={icon} size={18} color={colors.textMuted} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{title}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#525252',
color: colors.textMuted,
marginTop: 2,
lineHeight: 17,
}}

View File

@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next';
import { SuccessAlert } from '../SuccessAlert';
import { ConfirmAlert } from '../ConfirmAlert';
import type { CustomDomain, Tier } from '../../hooks/useCustomDomains';
import { useColors } from '../../lib/theme';
// ─── Helpers ─────────────────────────────────────────────────────────────
@ -65,6 +66,7 @@ const STATUS_PRIORITY: Record<string, number> = {
export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Props) {
const { t } = useTranslation();
const colors = useColors();
// Slot-relevante Domains (alles außer approved). Sortiert nach Status-Priority,
// innerhalb gleicher Priority dann newest-first by addedAt.
const visible = useMemo(() => {
@ -85,7 +87,7 @@ export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Pro
<View style={{ gap: 12 }}>
{/* Header: Section-Title + Slot-Counter + Add-Button (inline, neben SlotPill) */}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('blocker.domain_section_title')}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
@ -122,7 +124,7 @@ export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Pro
const pct = (tier.usedSlots / tier.domainLimit) * 100;
const barColor = pct >= 90 ? '#dc2626' : pct >= 60 ? '#f59e0b' : '#16a34a';
return (
<View style={{ height: 4, borderRadius: 2, backgroundColor: '#f0f0f0', overflow: 'hidden' }}>
<View style={{ height: 4, borderRadius: 2, backgroundColor: colors.surfaceElevated, overflow: 'hidden' }}>
<View
style={{
height: '100%',
@ -174,16 +176,16 @@ export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Pro
borderRadius: 14,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: '#d4d4d4',
borderColor: colors.border,
alignItems: 'center',
}}
>
<Ionicons name="globe-outline" size={28} color="#a3a3a3" />
<Ionicons name="globe-outline" size={28} color={colors.textMuted} />
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#737373',
color: colors.textMuted,
marginTop: 8,
textAlign: 'center',
}}
@ -205,8 +207,9 @@ export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Pro
// ─── SlotPill ─────────────────────────────────────────────────────────────
function SlotPill({ tier }: { tier: Tier }) {
const bg = tier.atLimit ? '#fee2e2' : '#f5f5f5';
const fg = tier.atLimit ? '#dc2626' : '#525252';
const colors = useColors();
const bg = tier.atLimit ? '#fee2e2' : colors.surfaceElevated;
const fg = tier.atLimit ? '#dc2626' : colors.textMuted;
return (
<View
style={{
@ -258,6 +261,7 @@ function DomainTile({
onSubmit?: (id: string) => Promise<{ ok: boolean }>;
}) {
const { t } = useTranslation();
const colors = useColors();
const [submitting, setSubmitting] = useState(false);
const [imgError, setImgError] = useState(false);
const [successVisible, setSuccessVisible] = useState(false);
@ -346,9 +350,9 @@ function DomainTile({
return (
<View
style={{
backgroundColor: '#fff',
backgroundColor: colors.bg,
borderWidth: 1,
borderColor: '#e5e5e5',
borderColor: colors.border,
borderRadius: 14,
padding: 8,
// KEIN aspectRatio:1 mehr — der hat den Button auf 0 Höhe gepresst.
@ -417,7 +421,7 @@ function DomainTile({
style={{
fontSize: 10,
fontFamily: 'Nunito_600SemiBold',
color: '#0a0a0a',
color: colors.text,
textAlign: 'center',
width: '100%',
}}

View File

@ -2,7 +2,7 @@ import { View, Text, Switch, Pressable, ActivityIndicator } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import type { ProtectionState } from '../../lib/protection';
import { colors } from '../../lib/theme';
import { useColors } from '../../lib/theme';
type Props = {
state: ProtectionState;
@ -15,6 +15,7 @@ type Props = {
export function ProtectionCard({ state, loading, onActivate, onPressSettings }: Props) {
const { t } = useTranslation();
const colors = useColors();
const isActive = state.phase === 'active' || state.phase === 'cooldownActive';
const isCooldown = state.phase === 'cooldownActive';
@ -30,8 +31,8 @@ export function ProtectionCard({ state, loading, onActivate, onPressSettings }:
return t('blocker.protection_subtitle_pro');
})();
const cardBg = isCooldown ? '#fef3c7' : isActive ? '#dcfce7' : '#ffffff';
const cardBorder = isCooldown ? '#fcd34d' : isActive ? '#86efac' : '#e5e5e5';
const cardBg = isCooldown ? '#fef3c7' : isActive ? '#dcfce7' : colors.bg;
const cardBorder = isCooldown ? '#fcd34d' : isActive ? '#86efac' : colors.border;
const iconBg = isCooldown ? '#fde68a' : isActive ? '#bbf7d0' : '#f5f5f5';
const iconColor = isCooldown ? '#d97706' : isActive ? '#16a34a' : '#a3a3a3';
@ -67,7 +68,7 @@ export function ProtectionCard({ state, loading, onActivate, onPressSettings }:
style={{
fontSize: 15,
fontFamily: 'Nunito_700Bold',
color: '#0a0a0a',
color: colors.text,
}}
>
{t('blocker.protection_card_title')}
@ -76,7 +77,7 @@ export function ProtectionCard({ state, loading, onActivate, onPressSettings }:
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#525252',
color: colors.textMuted,
marginTop: 2,
}}
>
@ -100,7 +101,7 @@ export function ProtectionCard({ state, loading, onActivate, onPressSettings }:
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#ffffff',
backgroundColor: colors.surface,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
@ -108,7 +109,7 @@ export function ProtectionCard({ state, loading, onActivate, onPressSettings }:
shadowOpacity: 0.05,
shadowRadius: 2,
}}>
<Ionicons name="settings-outline" size={18} color="#525252" />
<Ionicons name="settings-outline" size={18} color={colors.textMuted} />
</View>
</Pressable>
) : (
@ -146,18 +147,19 @@ export function ProtectionCard({ state, loading, onActivate, onPressSettings }:
function Stat({
label,
value,
valueColor = '#0a0a0a',
valueColor,
}: {
label: string;
value: string;
valueColor?: string;
}) {
const colors = useColors();
return (
<View style={{ flex: 1, alignItems: 'center' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_800ExtraBold', color: valueColor }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_800ExtraBold', color: valueColor ?? colors.text }}>
{value}
</Text>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#737373' }}>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{label}
</Text>
</View>

View File

@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next';
import Svg, { Path, Circle } from 'react-native-svg';
import type { ProtectionState } from '../../lib/protection';
import { apiFetch } from '../../lib/api';
import { useColors } from '../../lib/theme';
type Props = {
visible: boolean;
@ -55,6 +56,7 @@ export function ProtectionDetailsSheet({
onRequestDeactivation,
}: Props) {
const { t, i18n } = useTranslation();
const colors = useColors();
const localeTag = i18n.language === 'de' ? 'de-DE' : 'en-US';
const sheetHeight = useRef(new Animated.Value(DEFAULT_HEIGHT)).current;
@ -162,7 +164,7 @@ export function ProtectionDetailsSheet({
<Animated.View
style={{
flex: 1,
backgroundColor: '#fff',
backgroundColor: colors.bg,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: 'hidden',
@ -178,7 +180,7 @@ export function ProtectionDetailsSheet({
{...panResponder.panHandlers}
style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 6 }}
>
<View style={{ width: 36, height: 5, borderRadius: 3, backgroundColor: '#d4d4d8' }} />
<View style={{ width: 36, height: 5, borderRadius: 3, backgroundColor: colors.border }} />
</View>
{/* Header */}
@ -192,15 +194,15 @@ export function ProtectionDetailsSheet({
paddingTop: 4,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
borderBottomColor: colors.border,
}}
>
<View style={{ width: 50 }} />
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('blocker.details_title')}
</Text>
<Pressable onPress={handleClose} hitSlop={10} style={{ width: 50, alignItems: 'flex-end' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('blocker.details_done')}
</Text>
</Pressable>
@ -249,16 +251,16 @@ export function ProtectionDetailsSheet({
padding: 18,
borderRadius: 16,
borderWidth: 1,
borderColor: '#e5e5e5',
backgroundColor: '#fff',
borderColor: colors.border,
backgroundColor: colors.bg,
gap: 8,
}}
>
<View>
<Text style={{ fontSize: 13, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>
<Text style={{ fontSize: 13, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
{t('blocker.kpi_submissions_title')}
</Text>
<Text style={{ fontSize: 11, color: '#737373', fontFamily: 'Nunito_400Regular', marginTop: 2 }}>
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_400Regular', marginTop: 2 }}>
{t('blocker.kpi_submissions_subtitle')}
</Text>
</View>
@ -323,14 +325,14 @@ export function ProtectionDetailsSheet({
flex: 1,
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: '#737373',
color: colors.textMuted,
letterSpacing: 0.5,
textTransform: 'uppercase',
}}
>
{t('blocker.faq_heading')}
</Text>
<Ionicons name="help-circle-outline" size={18} color="#737373" />
<Ionicons name="help-circle-outline" size={18} color={colors.textMuted} />
</View>
{[1, 2, 3, 4].map((n) => (
<FaqItem
@ -355,7 +357,7 @@ export function ProtectionDetailsSheet({
borderRadius: 12,
borderWidth: 1.5,
borderColor: HERO_COLOR,
backgroundColor: '#fff7ed',
backgroundColor: colors.surface,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
@ -479,6 +481,7 @@ function KpiCard({
decimals?: number;
suffix?: string;
}) {
const colors = useColors();
return (
<View
style={{
@ -486,14 +489,14 @@ function KpiCard({
padding: 12,
borderRadius: 12,
borderWidth: 1,
borderColor: '#e5e5e5',
backgroundColor: '#fafafa',
borderColor: colors.border,
backgroundColor: colors.surface,
gap: 6,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Ionicons name={icon} size={14} color="#737373" />
<Text style={{ flex: 1, fontSize: 10, color: '#737373', fontFamily: 'Nunito_400Regular', lineHeight: 13 }}>
<Ionicons name={icon} size={14} color={colors.textMuted} />
<Text style={{ flex: 1, fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 13 }}>
{label}
</Text>
</View>
@ -502,10 +505,10 @@ function KpiCard({
value={value}
locale={locale}
decimals={decimals}
style={{ fontSize: 18, fontFamily: 'Nunito_900Black', color: '#0a0a0a', letterSpacing: -0.3 }}
style={{ fontSize: 18, fontFamily: 'Nunito_900Black', color: colors.text, letterSpacing: -0.3 }}
/>
{suffix ? (
<Text style={{ fontSize: 10, color: '#737373', fontFamily: 'Nunito_700Bold' }}>{suffix}</Text>
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_700Bold' }}>{suffix}</Text>
) : null}
</View>
</View>
@ -522,13 +525,14 @@ function LegendItem({
label: string;
value: number;
}) {
const colors = useColors();
return (
<View style={{ alignItems: 'center', gap: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 5 }}>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: color }} />
<Text style={{ fontSize: 11, color: '#525252', fontFamily: 'Nunito_700Bold' }}>{value}</Text>
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_700Bold' }}>{value}</Text>
</View>
<Text style={{ fontSize: 10, color: '#737373', fontFamily: 'Nunito_400Regular' }}>{label}</Text>
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>{label}</Text>
</View>
);
}
@ -543,6 +547,7 @@ function HalfDonut({
centerValue: number;
centerLabel: string;
}) {
const colors = useColors();
const total = Math.max(1, segments.reduce((s, x) => s + x.value, 0));
const W = 220;
@ -582,7 +587,7 @@ function HalfDonut({
{/* Background track */}
<Path
d={arcPath(cx, cy, r, 180, 360)}
stroke="#f0f0f0"
stroke={colors.surfaceElevated}
strokeWidth={stroke}
fill="none"
strokeLinecap="round"
@ -618,10 +623,10 @@ function HalfDonut({
alignItems: 'center',
}}
>
<Text style={{ fontSize: 30, fontFamily: 'Nunito_900Black', color: '#0a0a0a', letterSpacing: -0.5 }}>
<Text style={{ fontSize: 30, fontFamily: 'Nunito_900Black', color: colors.text, letterSpacing: -0.5 }}>
{centerValue}
</Text>
<Text style={{ fontSize: 10, color: '#737373', fontFamily: 'Nunito_400Regular', marginTop: -2 }}>
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular', marginTop: -2 }}>
{centerLabel}
</Text>
</View>
@ -643,6 +648,7 @@ function polar(cx: number, cy: number, r: number, angleDeg: number) {
// ─── FAQ Item (chevron AT END of header row, on right) ─────────────────────
function FaqItem({ question, answer }: { question: string; answer: string }) {
const colors = useColors();
const [open, setOpen] = useState(false);
const rotateAnim = useRef(new Animated.Value(0)).current;
@ -664,10 +670,10 @@ function FaqItem({ question, answer }: { question: string; answer: string }) {
style={{
alignSelf: 'stretch',
borderWidth: 1,
borderColor: '#e5e5e5',
borderColor: colors.border,
borderRadius: 12,
overflow: 'hidden',
backgroundColor: '#fff',
backgroundColor: colors.bg,
}}
>
<Pressable
@ -678,7 +684,7 @@ function FaqItem({ question, answer }: { question: string; answer: string }) {
>
<View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 14 }}>
<View style={{ flex: 1, paddingRight: 12 }}>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#0a0a0a', lineHeight: 18 }}>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: colors.text, lineHeight: 18 }}>
{question}
</Text>
</View>
@ -687,19 +693,19 @@ function FaqItem({ question, answer }: { question: string; answer: string }) {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#f5f5f5',
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
transform: [{ rotate }],
}}
>
<Ionicons name="chevron-down" size={16} color="#525252" />
<Ionicons name="chevron-down" size={16} color={colors.textMuted} />
</Animated.View>
</View>
</Pressable>
{open && (
<View style={{ paddingHorizontal: 14, paddingBottom: 14, paddingTop: 0 }}>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: '#525252', lineHeight: 19 }}>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 19 }}>
{answer}
</Text>
</View>

View File

@ -2,6 +2,7 @@ import { View, Text, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import type { ProtectionState } from '../../lib/protection';
import { useColors } from '../../lib/theme';
type Props = {
state: ProtectionState;
@ -16,6 +17,7 @@ type Props = {
*/
export function ProtectionLockedCard({ state, onPressSettings }: Props) {
const { t } = useTranslation();
const colors = useColors();
const isCooldown = state.phase === 'cooldownActive';
const cardBg = isCooldown ? '#fef3c7' : '#dcfce7';
const cardBorder = isCooldown ? '#fcd34d' : '#86efac';
@ -57,14 +59,14 @@ export function ProtectionLockedCard({ state, onPressSettings }: Props) {
<Ionicons name="shield-checkmark" size={22} color={iconColor} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('blocker.protection_card_locked_title')}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#525252',
color: colors.textMuted,
marginTop: 2,
}}
>
@ -84,7 +86,7 @@ export function ProtectionLockedCard({ state, onPressSettings }: Props) {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#ffffff',
backgroundColor: colors.surface,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
@ -92,7 +94,7 @@ export function ProtectionLockedCard({ state, onPressSettings }: Props) {
shadowOpacity: 0.05,
shadowRadius: 2,
}}>
<Ionicons name="settings-outline" size={18} color="#525252" />
<Ionicons name="settings-outline" size={18} color={colors.textMuted} />
</View>
</Pressable>
</View>
@ -118,11 +120,12 @@ export function ProtectionLockedCard({ state, onPressSettings }: Props) {
);
}
function Stat({ label, value, valueColor = '#0a0a0a' }: { label: string; value: string; valueColor?: string }) {
function Stat({ label, value, valueColor }: { label: string; value: string; valueColor?: string }) {
const colors = useColors();
return (
<View style={{ flex: 1, alignItems: 'center' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_800ExtraBold', color: valueColor }}>{value}</Text>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#737373' }}>{label}</Text>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_800ExtraBold', color: valueColor ?? colors.text }}>{value}</Text>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>{label}</Text>
</View>
);
}

View File

@ -13,6 +13,7 @@ import * as Clipboard from 'expo-clipboard';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { resolveAvatar } from '../../lib/resolveAvatar';
import { useColors } from '../../lib/theme';
export type ChatMsg = {
id: string;
@ -63,6 +64,8 @@ export function ChatBubble({
onOpenImage,
}: Props) {
const { t } = useTranslation();
const colors = useColors();
const styles = makeStyles(colors);
const [actionsOpen, setActionsOpen] = useState(false);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -323,7 +326,8 @@ export function ChatBubble({
);
}
const styles = StyleSheet.create({
function makeStyles(colors: ReturnType<typeof useColors>) {
return StyleSheet.create({
row: {
flexDirection: 'row',
paddingHorizontal: 8,
@ -337,7 +341,7 @@ const styles = StyleSheet.create({
width: 26,
height: 26,
borderRadius: 13,
backgroundColor: '#e5e5e5',
backgroundColor: colors.surfaceElevated,
},
bubbleCol: {
maxWidth: '78%',
@ -362,9 +366,9 @@ const styles = StyleSheet.create({
backgroundColor: '#007AFF',
},
bubbleOther: {
backgroundColor: '#ffffff',
backgroundColor: colors.surface,
borderWidth: StyleSheet.hairlineWidth,
borderColor: '#e5e5e5',
borderColor: colors.border,
},
replyPreview: {
borderLeftWidth: 3,
@ -381,7 +385,7 @@ const styles = StyleSheet.create({
image: {
width: 220,
height: 220,
backgroundColor: '#f5f5f5',
backgroundColor: colors.surfaceElevated,
},
imageTimeOverlay: {
position: 'absolute',
@ -412,7 +416,7 @@ const styles = StyleSheet.create({
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: '#fff',
backgroundColor: colors.bg,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
padding: 8,
@ -422,7 +426,7 @@ const styles = StyleSheet.create({
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: '#d4d4d4',
backgroundColor: colors.border,
alignSelf: 'center',
marginBottom: 10,
},
@ -436,7 +440,8 @@ const styles = StyleSheet.create({
sheetText: {
fontSize: 15,
fontFamily: 'Nunito_600SemiBold',
color: '#171717',
color: colors.text,
marginLeft: 12,
},
});
});
}

View File

@ -11,10 +11,12 @@ import {
Alert,
} from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system';
// TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14
import * as FileSystem from 'expo-file-system/legacy';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { supabase } from '../../lib/supabase';
import { useColors } from '../../lib/theme';
type ReplyTo = { id: string; nickname: string; content: string };
@ -44,6 +46,7 @@ export function ChatInput({
onCancelReply,
}: Props) {
const { t } = useTranslation();
const colors = useColors();
const [text, setText] = useState('');
const [attachment, setAttachment] = useState<{
uri: string;
@ -136,6 +139,8 @@ export function ChatInput({
setAttachment(null);
}
const styles = makeStyles(colors);
return (
<View style={styles.container}>
{/* Reply preview */}
@ -230,18 +235,19 @@ function decodeBase64(base64: string): Uint8Array {
return bytes;
}
const styles = StyleSheet.create({
function makeStyles(colors: ReturnType<typeof useColors>) {
return StyleSheet.create({
container: {
backgroundColor: '#ffffff',
backgroundColor: colors.bg,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: '#e5e5e5',
borderTopColor: colors.border,
},
replyBar: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: '#eff6ff',
backgroundColor: colors.surface,
borderLeftWidth: 3,
borderLeftColor: '#007AFF',
marginHorizontal: 8,
@ -256,7 +262,7 @@ const styles = StyleSheet.create({
replyContent: {
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: '#525252',
color: colors.textMuted,
marginTop: 1,
},
attachBar: {
@ -264,7 +270,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: '#fafafa',
backgroundColor: colors.surface,
marginHorizontal: 8,
marginTop: 6,
borderRadius: 8,
@ -279,7 +285,7 @@ const styles = StyleSheet.create({
width: 36,
height: 36,
borderRadius: 6,
backgroundColor: '#e5e5e5',
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
marginRight: 8,
@ -288,7 +294,7 @@ const styles = StyleSheet.create({
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#171717',
color: colors.text,
},
row: {
flexDirection: 'row',
@ -307,7 +313,7 @@ const styles = StyleSheet.create({
},
inputWrap: {
flex: 1,
backgroundColor: '#f5f5f5',
backgroundColor: colors.surfaceElevated,
borderRadius: 22,
paddingHorizontal: 14,
minHeight: 36,
@ -318,7 +324,7 @@ const styles = StyleSheet.create({
fontSize: 14,
lineHeight: 19,
fontFamily: 'Nunito_400Regular',
color: '#171717',
color: colors.text,
paddingVertical: Platform.OS === 'ios' ? 8 : 4,
},
sendBtn: {
@ -329,4 +335,5 @@ const styles = StyleSheet.create({
justifyContent: 'center',
marginLeft: 6,
},
});
});
}

View File

@ -1,17 +1,18 @@
import { useState } from 'react';
import {
Modal,
View,
Text,
TextInput,
Pressable,
StyleSheet,
ActivityIndicator,
Platform,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '../../lib/api';
import { useColors } from '../../lib/theme';
import { KeyboardAwareSheet } from '../KeyboardAwareSheet';
const COLLAPSED_HEIGHT = 480;
type Props = {
visible: boolean;
@ -21,6 +22,8 @@ type Props = {
export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
const { t } = useTranslation();
const colors = useColors();
const styles = makeStyles(colors);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [isPublic, setIsPublic] = useState(true);
@ -34,6 +37,11 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
setJoinMode('approval');
}
function handleClose() {
reset();
onClose();
}
async function create() {
const trimmed = name.trim();
if (!trimmed || creating) return;
@ -59,10 +67,14 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
}
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={styles.backdrop} onPress={onClose}>
<Pressable style={styles.sheet} onPress={() => {}}>
<View style={styles.grabber} />
<KeyboardAwareSheet
visible={visible}
onClose={handleClose}
collapsedHeight={COLLAPSED_HEIGHT}
pushChildrenToBottom={false}
topRadius={22}
>
<View style={{ flex: 1, paddingHorizontal: 18, paddingTop: 6 }}>
<Text style={styles.title}>{t('chat.create_group')}</Text>
<TextInput
@ -84,10 +96,7 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
/>
{/* Public toggle */}
<Pressable
style={styles.toggleRow}
onPress={() => setIsPublic((v) => !v)}
>
<Pressable style={styles.toggleRow} onPress={() => setIsPublic((v) => !v)}>
<Text style={styles.toggleLabel}>{t('chat.public_room')}</Text>
<View style={[styles.toggle, isPublic && styles.toggleOn]}>
<View style={[styles.toggleKnob, isPublic && styles.toggleKnobOn]} />
@ -119,18 +128,17 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
</View>
)}
<View style={{ flex: 1 }} />
{/* Actions */}
<View style={styles.actions}>
<Pressable onPress={onClose} style={styles.cancelBtn}>
<Pressable onPress={handleClose} style={styles.cancelBtn}>
<Text style={styles.cancelText}>{t('common.cancel')}</Text>
</Pressable>
<Pressable
onPress={create}
disabled={!name.trim() || creating}
style={[
styles.createBtn,
{ opacity: !name.trim() || creating ? 0.5 : 1 },
]}
style={[styles.createBtn, { opacity: !name.trim() || creating ? 0.5 : 1 }]}
>
{creating ? (
<ActivityIndicator size="small" color="#fff" />
@ -139,47 +147,27 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
)}
</Pressable>
</View>
</Pressable>
</Pressable>
</Modal>
</View>
</KeyboardAwareSheet>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: '#fff',
borderTopLeftRadius: 22,
borderTopRightRadius: 22,
padding: 18,
paddingBottom: Platform.OS === 'ios' ? 32 : 18,
},
grabber: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: '#d4d4d4',
alignSelf: 'center',
marginBottom: 12,
},
function makeStyles(colors: ReturnType<typeof useColors>) {
return StyleSheet.create({
title: {
fontSize: 17,
fontFamily: 'Nunito_700Bold',
color: '#171717',
color: colors.text,
marginBottom: 14,
},
input: {
backgroundColor: '#f5f5f5',
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 14,
fontFamily: 'Nunito_400Regular',
color: '#171717',
color: colors.text,
marginBottom: 10,
},
toggleRow: {
@ -192,13 +180,13 @@ const styles = StyleSheet.create({
toggleLabel: {
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: '#171717',
color: colors.text,
},
toggle: {
width: 46,
height: 28,
borderRadius: 14,
backgroundColor: '#e5e5e5',
backgroundColor: colors.surfaceElevated,
padding: 2,
justifyContent: 'center',
},
@ -209,7 +197,7 @@ const styles = StyleSheet.create({
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: '#fff',
backgroundColor: colors.bg,
shadowColor: '#000',
shadowOpacity: 0.15,
shadowRadius: 2,
@ -222,7 +210,7 @@ const styles = StyleSheet.create({
subLabel: {
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#737373',
color: colors.textMuted,
marginBottom: 6,
},
modeRow: {
@ -233,29 +221,30 @@ const styles = StyleSheet.create({
paddingVertical: 8,
borderRadius: 10,
borderWidth: 1,
borderColor: '#e5e5e5',
borderColor: colors.border,
alignItems: 'center',
marginRight: 6,
},
modeBtnActive: {
backgroundColor: '#eff6ff',
backgroundColor: colors.surface,
borderColor: '#007AFF',
},
modeBtnText: {
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#737373',
color: colors.textMuted,
},
modeBtnTextActive: {
color: '#007AFF',
},
actions: {
flexDirection: 'row',
marginTop: 20,
marginTop: 4,
marginBottom: 10,
},
cancelBtn: {
flex: 1,
backgroundColor: '#f5f5f5',
backgroundColor: colors.surfaceElevated,
paddingVertical: 12,
borderRadius: 12,
alignItems: 'center',
@ -264,7 +253,7 @@ const styles = StyleSheet.create({
cancelText: {
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: '#171717',
color: colors.text,
},
createBtn: {
flex: 1,
@ -279,4 +268,5 @@ const styles = StyleSheet.create({
fontFamily: 'Nunito_700Bold',
color: '#fff',
},
});
});
}

View File

@ -1,6 +1,7 @@
import { View, Text, Pressable, Image, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
export type Room = {
id: string;
@ -29,6 +30,8 @@ function formatTime(ts: string, justNow: string) {
export function RoomCard({ room, onPress }: Props) {
const { t } = useTranslation();
const colors = useColors();
const styles = makeStyles(colors);
const initials = room.name
.split(' ')
.slice(0, 2)
@ -42,7 +45,7 @@ export function RoomCard({ room, onPress }: Props) {
<View
style={[
styles.avatar,
{ backgroundColor: room.isPublic ? '#eff6ff' : '#e5e5e5' },
{ backgroundColor: room.isPublic ? colors.surface : colors.surfaceElevated },
]}
>
{room.avatarUrl ? (
@ -102,16 +105,17 @@ export function RoomCard({ room, onPress }: Props) {
);
}
const styles = StyleSheet.create({
function makeStyles(colors: ReturnType<typeof useColors>) {
return StyleSheet.create({
row: {
width: '100%',
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 11,
backgroundColor: '#fff',
backgroundColor: colors.bg,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#f5f5f5',
borderBottomColor: colors.border,
},
avatar: {
width: 42,
@ -129,7 +133,7 @@ const styles = StyleSheet.create({
avatarInitials: {
fontSize: 13,
fontFamily: 'Nunito_700Bold',
color: '#525252',
color: colors.textMuted,
},
info: {
flex: 1,
@ -155,19 +159,19 @@ const styles = StyleSheet.create({
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
backgroundColor: '#f5f5f5',
backgroundColor: colors.surfaceElevated,
},
name: {
fontSize: 14,
fontFamily: 'Nunito_700Bold',
color: '#171717',
color: colors.text,
flexShrink: 1,
},
defaultBadge: {
marginLeft: 6,
paddingHorizontal: 6,
paddingVertical: 1,
backgroundColor: '#eff6ff',
backgroundColor: colors.surface,
borderRadius: 8,
},
defaultBadgeText: {
@ -178,12 +182,12 @@ const styles = StyleSheet.create({
lastMessage: {
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#737373',
color: colors.textMuted,
},
description: {
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#a3a3a3',
color: colors.textMuted,
},
right: {
alignItems: 'flex-end',
@ -192,13 +196,13 @@ const styles = StyleSheet.create({
memberCount: {
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: '#737373',
color: colors.textMuted,
marginLeft: 3,
},
time: {
fontSize: 10,
fontFamily: 'Nunito_500Medium',
color: '#a3a3a3',
color: colors.textMuted,
marginLeft: 'auto',
paddingLeft: 6,
},
@ -206,7 +210,7 @@ const styles = StyleSheet.create({
marginLeft: 6,
paddingHorizontal: 8,
paddingVertical: 3,
backgroundColor: '#eff6ff',
backgroundColor: colors.surface,
borderRadius: 10,
},
joinBadgeText: {
@ -214,4 +218,5 @@ const styles = StyleSheet.create({
fontFamily: 'Nunito_700Bold',
color: '#007AFF',
},
});
});
}

View File

@ -1,256 +1,505 @@
import { useEffect, useRef } from 'react';
import { Animated, Pressable, Text, View } from 'react-native';
import { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Animated,
Easing,
Keyboard,
Modal,
Platform,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as Haptics from 'expo-haptics';
import { Ionicons } from '@expo/vector-icons';
import { RiveAvatar } from '../RiveAvatar';
import { StarRating } from './StarRating';
import { useColors } from '../../lib/theme';
import { apiFetch } from '../../lib/api';
export type GameOverScreenProps = {
score: number;
bestScore: number;
gameName: string;
scoreLabel?: string;
goodScore?: number;
onRetry: () => void;
onExit: () => void;
isNewBest?: boolean;
};
const MOTIVATIONAL_KEYS = [
'gameOver.motivational_0',
'gameOver.motivational_1',
'gameOver.motivational_2',
'gameOver.motivational_3',
'gameOver.motivational_4',
];
function lyraMsg(
gameName: string,
score: number,
goodScore: number,
isNewBest: boolean,
t: (k: string) => string
): { title: string; body: string } {
if (isNewBest) return { title: t('gameOver.lyra_title_record'), body: t('gameOver.lyra_body_record') };
if (score >= goodScore) return { title: t('gameOver.lyra_title_good'), body: t('gameOver.lyra_body_good') };
if (score > 0) return { title: t('gameOver.lyra_title_ok'), body: t('gameOver.lyra_body_ok') };
return { title: t('gameOver.lyra_title_low'), body: t('gameOver.lyra_body_low') };
}
export function GameOverScreen({
score,
bestScore,
gameName,
scoreLabel,
goodScore = 5,
onRetry,
onExit,
isNewBest = false,
}: GameOverScreenProps) {
const { t } = useTranslation();
const colors = useColors();
const insets = useSafeAreaInsets();
const slideAnim = useRef(new Animated.Value(40)).current;
const fadeAnim = useRef(new Animated.Value(0)).current;
// Slide-In Spring für den Sheet-Auftritt (eigene Bouncy-Animation behalten)
const slideAnim = useRef(new Animated.Value(500)).current;
// Keyboard-Lift via plain RN Keyboard.addListener (funktioniert in Modals,
// anders als react-native-keyboard-controller's useKeyboardAnimation).
const keyboardLift = useRef(new Animated.Value(0)).current;
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const showSub = Keyboard.addListener(showEvent, (e) => {
Animated.timing(keyboardLift, {
toValue: e.endCoordinates.height,
duration: Platform.OS === 'ios' ? (e.duration ?? 250) : 220,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
});
const hideSub = Keyboard.addListener(hideEvent, (e) => {
Animated.timing(keyboardLift, {
toValue: 0,
duration: Platform.OS === 'ios' ? (e?.duration ?? 250) : 220,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
});
return () => {
showSub.remove();
hideSub.remove();
};
}, [keyboardLift]);
const [rating, setRating] = useState(0);
const [feedback, setFeedback] = useState('');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [shareSectionOpen, setShareSectionOpen] = useState(false);
const [shareText, setShareText] = useState('');
const [shareTextLoading, setShareTextLoading] = useState(false);
const [sharing, setSharing] = useState(false);
const [posted, setPosted] = useState(false);
const [postError, setPostError] = useState(false);
const emotion = isNewBest || score >= goodScore ? 'happy' : 'empathy';
const msg = lyraMsg(gameName, score, goodScore, isNewBest, t);
const displayScore = score;
const displayBest = Math.max(score, bestScore);
useEffect(() => {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
Animated.parallel([
Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, tension: 60, friction: 10 }),
Animated.timing(fadeAnim, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
Animated.spring(slideAnim, {
toValue: 0,
useNativeDriver: true,
damping: 22,
stiffness: 200,
mass: 0.8,
}).start();
}, []);
const motivationalKey = MOTIVATIONAL_KEYS[score % MOTIVATIONAL_KEYS.length]!;
const fmt = (n: number) => String(n).padStart(5, '0');
// Negativer Lift — translateY -keyboardHeight schiebt Sheet nach oben.
const keyboardLiftY = Animated.multiply(keyboardLift, -1);
function handleExit() {
Animated.timing(slideAnim, {
toValue: 500,
duration: 220,
useNativeDriver: true,
}).start(() => onExit());
}
async function submitRating() {
setSaving(true);
try {
await apiFetch('/api/games/rating', {
method: 'POST',
body: {
gameName: gameName.toLowerCase(),
stars: rating,
feedback: feedback.trim() || null,
score,
},
});
setSaved(true);
} catch {
// endpoint not yet live — silent
} finally {
setSaving(false);
}
}
async function openShareSection() {
setShareTextLoading(true);
setShareSectionOpen(true);
try {
const data = await apiFetch<{ text: string }>('/api/games/share-text', {
method: 'POST',
body: {
gameName: gameName.toLowerCase(),
score,
scoreLabel,
bestScore,
isNewRecord: score > bestScore,
mode: 'game',
},
});
setShareText(data.text || `${gameName}: ${score} ${scoreLabel ?? 'Punkte'}\n${t('gameOver.share_challenge')}`);
} catch {
setShareText(`${gameName}: ${score} ${scoreLabel ?? 'Punkte'}\n${t('gameOver.share_challenge')}`);
} finally {
setShareTextLoading(false);
}
}
async function submitCommunityPost() {
if (!shareText.trim()) return;
setSharing(true);
setPostError(false);
try {
const scoreLine = `${scoreLabel ?? 'Score'}: ${score}`;
await apiFetch('/api/community/post', {
method: 'POST',
body: {
category: 'game_share',
content: `${gameName}\n${scoreLine}\n${shareText.trim()}`,
},
});
setPosted(true);
setShareSectionOpen(false);
setTimeout(() => handleExit(), 1500);
} catch (err) {
console.error('[gameover/post] failed:', err);
setPostError(true);
} finally {
setSharing(false);
}
}
const pillBg = colors.surfaceElevated;
const pillText = colors.text;
const pillMuted = colors.textMuted;
return (
<Modal visible transparent animationType="none" onRequestClose={handleExit}>
<View style={{ flex: 1, justifyContent: 'flex-end' }}>
<TouchableOpacity onPress={handleExit} activeOpacity={1} style={{ flex: 1 }} />
<Animated.View
style={{
position: 'absolute',
inset: 0,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 24,
opacity: fadeAnim,
transform: [
{ translateY: slideAnim },
{ translateY: keyboardLiftY },
],
backgroundColor: colors.surface,
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
paddingTop: 12,
paddingHorizontal: 20,
paddingBottom: insets.bottom + 24,
}}
>
{/* Backdrop */}
<Pressable
onPress={onExit}
{/* Grab-handle */}
<View
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.55)',
alignSelf: 'center',
width: 36,
height: 5,
borderRadius: 3,
backgroundColor: colors.textMuted,
opacity: 0.3,
marginBottom: 16,
}}
/>
{/* Card */}
<Animated.View
style={{
transform: [{ translateY: slideAnim }],
width: '100%',
maxWidth: 340,
backgroundColor: colors.surface,
borderRadius: 20,
paddingHorizontal: 20,
paddingTop: 24,
paddingBottom: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.18,
shadowRadius: 20,
elevation: 12,
gap: 16,
}}
<ScrollView
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ gap: 16, paddingBottom: 8 }}
>
{/* Title row */}
<View style={{ alignItems: 'center', gap: 4 }}>
<Text
style={{
fontFamily: 'Nunito_800ExtraBold',
fontSize: 22,
color: colors.text,
textAlign: 'center',
}}
>
{t('gameOver.title')}
{/* Lyra avatar + message */}
<View style={{ alignItems: 'center', gap: 8 }}>
<RiveAvatar emotion={emotion} size="md" />
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
Lyra
</Text>
<Text
style={{
fontFamily: 'Nunito_400Regular',
fontSize: 13,
color: colors.textMuted,
textAlign: 'center',
}}
>
{gameName}
<Text style={{ fontFamily: 'Nunito_800ExtraBold', fontSize: 18, color: colors.text, textAlign: 'center' }}>
{msg.title}
</Text>
<Text style={{ fontFamily: 'Nunito_400Regular', fontSize: 13, color: colors.textMuted, textAlign: 'center', lineHeight: 18, paddingHorizontal: 4 }}>
{msg.body}
</Text>
</View>
{/* Score row */}
<View
style={{
flexDirection: 'row',
justifyContent: 'center',
gap: 12,
}}
>
{/* Score */}
<View
style={{
flex: 1,
backgroundColor: colors.surfaceElevated,
borderRadius: 14,
paddingVertical: 12,
paddingHorizontal: 8,
alignItems: 'center',
gap: 2,
}}
>
<Text
style={{
fontFamily: 'Courier New' as any,
fontSize: 22,
color: '#00e680',
letterSpacing: 2,
fontVariant: ['tabular-nums'],
}}
>
{fmt(score)}
{/* Score pills */}
<View style={{ flexDirection: 'row', justifyContent: 'center', gap: 10 }}>
<View style={{ flex: 1, backgroundColor: pillBg, borderRadius: 14, paddingVertical: 12, paddingHorizontal: 8, alignItems: 'center', gap: 2 }}>
<Text style={{ fontFamily: 'Nunito_800ExtraBold', fontSize: 20, color: pillText }}>
{displayScore}
</Text>
<Text
style={{
fontSize: 10,
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 1,
fontFamily: 'Nunito_600SemiBold',
}}
>
{t('gameOver.score')}
<Text style={{ fontSize: 10, color: pillMuted, textTransform: 'uppercase', letterSpacing: 1, fontFamily: 'Nunito_600SemiBold' }}>
{scoreLabel ?? t('gameOver.score')}
</Text>
</View>
{/* Best */}
<View
style={{
flex: 1,
backgroundColor: isNewBest ? '#fef3c7' : colors.surfaceElevated,
borderRadius: 14,
backgroundColor: isNewBest ? '#e7f0ff' : pillBg,
borderRadius: 12,
borderWidth: isNewBest ? 1.5 : 0,
borderColor: isNewBest ? '#f59e0b' : 'transparent',
borderColor: isNewBest ? '#007AFF' : 'transparent',
paddingVertical: 12,
paddingHorizontal: 8,
alignItems: 'center',
gap: 2,
}}
>
<Text
style={{
fontFamily: 'Courier New' as any,
fontSize: 22,
color: isNewBest ? '#d97706' : colors.textMuted,
letterSpacing: 2,
fontVariant: ['tabular-nums'],
}}
>
{fmt(Math.max(score, bestScore))}
<Text style={{ fontFamily: 'Nunito_800ExtraBold', fontSize: 20, color: isNewBest ? '#0051d4' : pillMuted }}>
{displayBest}
</Text>
<Text
style={{
fontSize: 10,
color: isNewBest ? '#d97706' : colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 1,
fontFamily: 'Nunito_600SemiBold',
}}
>
<Text style={{ fontSize: 10, color: isNewBest ? '#0051d4' : pillMuted, textTransform: 'uppercase', letterSpacing: 1, fontFamily: 'Nunito_600SemiBold' }}>
{isNewBest ? t('gameOver.newBest') : t('gameOver.best')}
</Text>
</View>
</View>
{/* Motivational text */}
<Text
{/* Star rating */}
<View style={{ alignItems: 'center', gap: 6 }}>
<StarRating
value={rating}
size="lg"
interactive={!saved}
filledColor="#007AFF"
onChange={(v) => { if (!saved) setRating(v); }}
/>
{saved ? (
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
{t('gameOver.rating_saved')}
</Text>
) : null}
</View>
{/* Feedback textarea + save */}
{rating > 0 && !saved ? (
<View style={{ gap: 8 }}>
<TextInput
value={feedback}
onChangeText={setFeedback}
placeholder={t('gameOver.feedback_placeholder')}
placeholderTextColor={colors.textMuted}
multiline
numberOfLines={2}
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
padding: 12,
fontSize: 13,
color: colors.textMuted,
textAlign: 'center',
lineHeight: 19,
fontFamily: 'Nunito_400Regular',
paddingHorizontal: 4,
color: colors.text,
minHeight: 56,
textAlignVertical: 'top',
}}
/>
<TouchableOpacity
onPress={submitRating}
disabled={saving}
activeOpacity={0.7}
style={{
backgroundColor: '#007AFF',
borderRadius: 12,
minHeight: 40,
paddingVertical: 14,
paddingHorizontal: 20,
alignItems: 'center',
justifyContent: 'center',
opacity: saving ? 0.65 : 1,
}}
>
{t(motivationalKey)}
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#ffffff' }}>
{saving ? t('common.loading') : t('gameOver.save_rating')}
</Text>
</TouchableOpacity>
</View>
) : null}
{/* Buttons */}
<View style={{ flexDirection: 'row', gap: 10 }}>
<Pressable
{/* Primary action row */}
<View style={{ flexDirection: 'row', gap: 12 }}>
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
onRetry();
}}
style={({ pressed }) => ({
activeOpacity={0.85}
style={{
flex: 1,
paddingVertical: 13,
paddingHorizontal: 16,
borderRadius: 12,
backgroundColor: '#007AFF',
borderRadius: 12,
minHeight: 40,
paddingVertical: 10,
paddingHorizontal: 16,
alignItems: 'center',
opacity: pressed ? 0.75 : 1,
})}
justifyContent: 'center',
}}
>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: '#ffffff' }}>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#ffffff' }}>
{t('gameOver.retry')}
</Text>
</Pressable>
</TouchableOpacity>
<Pressable
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
onExit();
handleExit();
}}
style={({ pressed }) => ({
activeOpacity={0.75}
style={{
flex: 1,
paddingVertical: 13,
paddingHorizontal: 16,
backgroundColor: '#e5e7eb',
borderRadius: 12,
backgroundColor: colors.surfaceElevated,
minHeight: 40,
paddingVertical: 14,
paddingHorizontal: 20,
alignItems: 'center',
opacity: pressed ? 0.75 : 1,
})}
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.08)',
}}
>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: colors.textMuted }}>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#374151' }}>
{t('gameOver.exit')}
</Text>
</Pressable>
</TouchableOpacity>
</View>
{/* Share section */}
{posted ? (
<View style={{ alignItems: 'center', paddingVertical: 4, flexDirection: 'row', justifyContent: 'center', gap: 6 }}>
<Ionicons name="checkmark-circle" size={15} color={colors.success} />
<Text style={{ fontSize: 13, color: colors.success, fontFamily: 'Nunito_600SemiBold' }}>
{t('gameOver.posted')}
</Text>
</View>
) : !shareSectionOpen ? (
<TouchableOpacity
onPress={openShareSection}
activeOpacity={0.6}
style={{ alignItems: 'center', paddingVertical: 4 }}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Ionicons name="people-outline" size={15} color={colors.textMuted} />
<Text style={{ fontSize: 13, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
{t('gameOver.share_result')}
</Text>
</View>
</TouchableOpacity>
) : (
<View style={{ gap: 10 }}>
{shareTextLoading ? (
<View style={{ alignItems: 'center', paddingVertical: 12 }}>
<ActivityIndicator size="small" color={colors.textMuted} />
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular', marginTop: 6 }}>
{t('gameOver.share_loading')}
</Text>
</View>
) : (
<TextInput
value={shareText}
onChangeText={setShareText}
multiline
numberOfLines={4}
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
padding: 14,
fontSize: 14,
fontFamily: 'Nunito_400Regular',
color: colors.text,
minHeight: 100,
textAlignVertical: 'top',
}}
/>
)}
{postError ? (
<Text style={{ fontSize: 12, color: colors.error, fontFamily: 'Nunito_600SemiBold', textAlign: 'center' }}>
{t('gameOver.post_error')}
</Text>
) : null}
<View style={{ flexDirection: 'row', gap: 12 }}>
<TouchableOpacity
onPress={() => { setShareSectionOpen(false); setShareText(''); setPostError(false); }}
activeOpacity={0.7}
style={{
flex: 1,
backgroundColor: '#e5e7eb',
borderRadius: 12,
minHeight: 40,
paddingVertical: 14,
paddingHorizontal: 20,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.08)',
}}
>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#374151' }}>
{t('common.cancel')}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={submitCommunityPost}
disabled={!shareText.trim() || sharing || shareTextLoading}
activeOpacity={0.85}
style={{
flex: 1,
backgroundColor: '#007AFF',
borderRadius: 12,
minHeight: 40,
paddingVertical: 14,
paddingHorizontal: 20,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 6,
opacity: sharing || !shareText.trim() || shareTextLoading ? 0.55 : 1,
}}
>
{sharing ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<Ionicons name="paper-plane-outline" size={16} color="#ffffff" />
)}
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#ffffff' }}>
{t('gameOver.post_to_community')}
</Text>
</TouchableOpacity>
</View>
</View>
)}
</ScrollView>
</Animated.View>
</Animated.View>
</View>
</Modal>
);
}

View File

@ -0,0 +1,107 @@
import { useEffect, useRef } from 'react';
import { Animated, View, Text } from 'react-native';
/**
* Animierter Progress-Bar: aktueller Score vs. persönlicher Rekord.
*
* - Bar-Breite animiert zu `min(score / max(best, 1), 1) * 100%`
* - Bei `isNewBest=true`: Celebration-Animation (Gold-Pulse + Scale-Bounce + 🏆-Label)
* - Position direkt unter `<DigitalScore />` im Game-Layout
*
* Reusable für Snake / Tetris / Memory pro Spiel den passenden `score`/`best`
* reinreichen. Optional `boardWidth` damit die Bar exakt das Board-Edge matcht.
*/
export interface ScoreProgressBarProps {
score: number;
best: number;
isNewBest: boolean;
boardWidth: number;
}
export function ScoreProgressBar({ score, best, isNewBest, boardWidth }: ScoreProgressBarProps) {
const widthAnim = useRef(new Animated.Value(0)).current;
const celebrationAnim = useRef(new Animated.Value(0)).current;
// Bar-Breite zum aktuellen Score-Verhältnis
useEffect(() => {
const target = best > 0 ? Math.min(score / best, 1) : score > 0 ? 1 : 0;
Animated.timing(widthAnim, {
toValue: target,
duration: 280,
useNativeDriver: false,
}).start();
}, [score, best, widthAnim]);
// Celebration-Pulse bei neuem Rekord
useEffect(() => {
if (!isNewBest) {
celebrationAnim.setValue(0);
return;
}
Animated.sequence([
Animated.timing(celebrationAnim, { toValue: 1, duration: 280, useNativeDriver: false }),
Animated.timing(celebrationAnim, { toValue: 0, duration: 600, useNativeDriver: false }),
]).start();
}, [isNewBest, celebrationAnim]);
const widthInterp = widthAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0%', '100%'],
});
// Bar-Color: idle blau, beim Celebration-Pulse → gold
const barColor = celebrationAnim.interpolate({
inputRange: [0, 1],
outputRange: ['#007AFF', '#FFD60A'],
});
// Container leicht hochskalieren bei Celebration
const containerScale = celebrationAnim.interpolate({
inputRange: [0, 1],
outputRange: [1, 1.04],
});
return (
<Animated.View
style={{
width: boardWidth,
alignSelf: 'center',
marginBottom: 6,
transform: [{ scale: containerScale }],
}}
>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 3 }}>
<Text style={{ fontSize: 9, color: '#6b7280', letterSpacing: 1, fontFamily: 'Nunito_600SemiBold', textTransform: 'uppercase' }}>
{isNewBest ? '🏆 NEW RECORD' : 'PROGRESS'}
</Text>
<Text
style={{
fontSize: 9,
color: isNewBest ? '#b8860b' : '#6b7280',
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.5,
}}
>
{score} / {Math.max(best, score)}
</Text>
</View>
<View
style={{
height: 6,
borderRadius: 3,
backgroundColor: '#1f2937',
overflow: 'hidden',
}}
>
<Animated.View
style={{
height: '100%',
width: widthInterp,
backgroundColor: barColor,
borderRadius: 3,
}}
/>
</View>
</Animated.View>
);
}

View File

@ -3,6 +3,7 @@ import { useRouter, type RelativePathString } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../stores/auth';
import { useColors } from '../../lib/theme';
// Controlled-Modal-Pattern. Trigger ist NICHT in dieser Komponente — der
// Avatar im AppHeader öffnet das Modal via `visible`-Prop (User-Anweisung
@ -33,6 +34,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
const router = useRouter();
const { t } = useTranslation();
const { signOut } = useAuthStore();
const colors = useColors();
function nav(path: RelativePathString) {
onClose();
@ -93,7 +95,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
position: 'absolute',
top: topOffset,
right: 12,
backgroundColor: '#ffffff',
backgroundColor: colors.surface,
borderRadius: 18,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
@ -148,7 +150,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#a3a3a3',
color: colors.textMuted,
marginTop: 1,
}}
numberOfLines={1}
@ -156,11 +158,11 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
{t('appHeader.sosTagline')}
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color="#d4d4d8" />
<Ionicons name="chevron-forward" size={16} color={colors.border} />
</View>
</Pressable>
<View style={{ height: 1, backgroundColor: '#f0f0f0' }} />
<View style={{ height: 1, backgroundColor: colors.border }} />
{/* Profile · Settings · Games · [Debug DEV] */}
{items.map((item) => (
@ -170,7 +172,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
onClose();
void item.onSelect();
}}
android_ripple={{ color: '#e5e7eb' }}
android_ripple={{ color: colors.surfaceElevated }}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
<View
@ -184,14 +186,14 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
<Ionicons
name={item.icon}
size={18}
color="#737373"
color={colors.textMuted}
style={{ marginRight: 14 }}
/>
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: '#0a0a0a',
color: colors.text,
}}
>
{item.label}
@ -200,12 +202,12 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
</Pressable>
))}
<View style={{ height: 1, backgroundColor: '#f0f0f0' }} />
<View style={{ height: 1, backgroundColor: colors.border }} />
{/* Abmelden — neutral, nicht rot */}
<Pressable
onPress={handleLogout}
android_ripple={{ color: '#e5e7eb' }}
android_ripple={{ color: colors.surfaceElevated }}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
<View
@ -219,14 +221,14 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
<Ionicons
name="log-out-outline"
size={18}
color="#737373"
color={colors.textMuted}
style={{ marginRight: 14 }}
/>
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: '#0a0a0a',
color: colors.text,
}}
>
{t('headerMenu.logout')}

View File

@ -0,0 +1,30 @@
/**
* LanguageIcon custom SVG für Sprache-Setting (statt Ionicons language-outline).
*
* SVG-Source: User-provided (24×24 viewBox, currentColor stroke).
* Pattern: A-glyph + speech-bubble + Aa-letters Translation/Language-Picker affordance.
*/
import { Svg, G, Path } from 'react-native-svg';
type Props = {
size?: number;
color?: string;
};
export function LanguageIcon({ size = 24, color = 'currentColor' }: Props) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<G stroke={color} strokeLinecap="round" strokeWidth={2}>
<Path
strokeLinejoin="round"
d="M14 19c3.771 0 5.657 0 6.828-1.172S22 14.771 22 11s0-5.657-1.172-6.828S17.771 3 14 3h-4C6.229 3 4.343 3 3.172 4.172S2 7.229 2 11s0 5.657 1.172 6.828c.653.654 1.528.943 2.828 1.07"
/>
<Path d="M14 19c-1.236 0-2.598.5-3.841 1.145c-1.998 1.037-2.997 1.556-3.489 1.225s-.399-1.355-.212-3.404L6.5 17.5" />
<Path
strokeLinejoin="round"
d="m5.5 13.5l1-2m0 0l1.106-2.211a1 1 0 0 1 1.788 0L10.5 11.5m-4 0h4m0 0l1 2m1-6h1.982V9c0 .5-.496 1.5-1.487 1.5m3.964-3v2m0 0v4m0-4H18.5"
/>
</G>
</Svg>
);
}

View File

@ -1,13 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useState } from 'react';
import {
ActivityIndicator,
Animated,
Dimensions,
Easing,
KeyboardAvoidingView,
Linking,
Modal,
Platform,
Pressable,
ScrollView,
Text,
@ -18,9 +12,10 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useMailConnect, detectProvider, type MailProvider } from '../../hooks/useMailConnect';
import { useColors } from '../../lib/theme';
import { KeyboardAwareSheet } from '../KeyboardAwareSheet';
const SCREEN_HEIGHT = Dimensions.get('window').height;
const SHEET_HEIGHT = SCREEN_HEIGHT * 0.65;
const COLLAPSED_HEIGHT = 600;
type Props = {
visible: boolean;
@ -97,6 +92,7 @@ const PROVIDERS: ProviderConfig[] = [
*/
export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
const { t } = useTranslation();
const colors = useColors();
const insets = useSafeAreaInsets();
const { connect, connecting, error: connectError } = useMailConnect();
@ -107,29 +103,6 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
const [passwordVisible, setPasswordVisible] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const translateY = useRef(new Animated.Value(SHEET_HEIGHT)).current;
const backdropOpacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
translateY.setValue(SHEET_HEIGHT);
backdropOpacity.setValue(0);
Animated.parallel([
Animated.timing(translateY, {
toValue: 0,
duration: 280,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(backdropOpacity, {
toValue: 1,
duration: 220,
useNativeDriver: true,
}),
]).start();
}
}, [visible, translateY, backdropOpacity]);
function handleClose() {
setView('grid');
setSelectedProvider(null);
@ -178,47 +151,7 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
const detectedProvider = email.includes('@') ? detectProvider(email) : null;
const currentProvider = selectedProvider ?? null;
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={handleClose}>
{/* Backdrop */}
<Animated.View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
opacity: backdropOpacity,
}}
>
<Pressable style={{ flex: 1 }} onPress={handleClose} />
</Animated.View>
{/* Sheet */}
<Animated.View
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: SHEET_HEIGHT,
backgroundColor: '#fff',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
transform: [{ translateY }],
}}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={{ flex: 1 }}
>
{/* Drag-Handle */}
<View style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 4 }}>
<View style={{ width: 36, height: 4, borderRadius: 2, backgroundColor: '#d4d4d4' }} />
</View>
{/* Header */}
const header = (
<View
style={{
flexDirection: 'row',
@ -228,33 +161,39 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
paddingTop: 6,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
borderBottomColor: colors.border,
}}
>
{view === 'form' ? (
<Pressable onPress={handleBack} hitSlop={10}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('common.back')}
</Text>
</Pressable>
) : (
<Pressable onPress={handleClose} hitSlop={10}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('common.cancel')}
</Text>
</Pressable>
)}
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{view === 'form' && currentProvider
? t(currentProvider.labelKey)
: t('mail.connect_sheet_title')}
</Text>
<View style={{ width: 60 }} />
</View>
);
{/* Content */}
return (
<KeyboardAwareSheet
visible={visible}
onClose={handleClose}
collapsedHeight={COLLAPSED_HEIGHT}
header={header}
pushChildrenToBottom={false}
>
{view === 'grid' ? (
<ProviderGrid providers={PROVIDERS} onSelect={handleProviderSelect} t={t} />
) : (
@ -274,9 +213,7 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
t={t}
/>
)}
</KeyboardAvoidingView>
</Animated.View>
</Modal>
</KeyboardAwareSheet>
);
}
@ -293,6 +230,7 @@ function ProviderGrid({
onSelect: (p: ProviderConfig) => void;
t: (key: string) => string;
}) {
const colors = useColors();
return (
<ScrollView
style={{ flex: 1 }}
@ -303,7 +241,7 @@ function ProviderGrid({
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#737373',
color: colors.textMuted,
marginBottom: 4,
lineHeight: 18,
}}
@ -325,9 +263,9 @@ function ProviderGrid({
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: '#f9f9f9',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: '#e5e5e5',
borderColor: colors.border,
borderRadius: 14,
padding: 14,
}}>
@ -345,13 +283,13 @@ function ProviderGrid({
</View>
<View style={{ flex: 1 }}>
<Text
style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}
style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: colors.text }}
numberOfLines={1}
>
{t(p.labelKey)}
</Text>
</View>
<Ionicons name="chevron-forward" size={14} color="#d4d4d4" />
<Ionicons name="chevron-forward" size={14} color={colors.border} />
</View>
</Pressable>
))}
@ -394,6 +332,7 @@ function FormView({
insets,
t,
}: FormViewProps) {
const colors = useColors();
const canConnect = email.trim().length > 0 && password.trim().length > 0 && !connecting;
return (
@ -461,7 +400,7 @@ function FormView({
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#525252',
color: colors.textMuted,
marginBottom: 6,
}}
>
@ -471,19 +410,19 @@ function FormView({
value={email}
onChangeText={onEmailChange}
placeholder={t('mail.form_email_placeholder')}
placeholderTextColor="#a3a3a3"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
returnKeyType="next"
style={{
backgroundColor: '#f5f5f5',
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: '#0a0a0a',
color: colors.text,
}}
/>
</View>
@ -494,7 +433,7 @@ function FormView({
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#525252',
color: colors.textMuted,
marginBottom: 6,
}}
>
@ -505,21 +444,21 @@ function FormView({
value={password}
onChangeText={onPasswordChange}
placeholder={t('mail.form_password_placeholder')}
placeholderTextColor="#a3a3a3"
placeholderTextColor={colors.textMuted}
secureTextEntry={!passwordVisible}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
onSubmitEditing={onConnect}
style={{
backgroundColor: '#f5f5f5',
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
paddingRight: 46,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: '#0a0a0a',
color: colors.text,
}}
/>
<Pressable

View File

@ -1,24 +1,13 @@
import { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Animated,
Dimensions,
Easing,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
Text,
TextInput,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useState } from 'react';
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useMailConnect } from '../../hooks/useMailConnect';
import { useColors } from '../../lib/theme';
import { humanizeMailError } from '../../lib/mailErrors';
import { KeyboardAwareSheet } from '../KeyboardAwareSheet';
const SCREEN_HEIGHT = Dimensions.get('window').height;
const SHEET_HEIGHT = SCREEN_HEIGHT * 0.5;
const COLLAPSED_HEIGHT = 280;
type Props = {
visible: boolean;
@ -33,38 +22,19 @@ type Props = {
*/
export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Props) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const colors = useColors();
const { connect, connecting, error: connectError } = useMailConnect();
const [password, setPassword] = useState('');
const [passwordVisible, setPasswordVisible] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const translateY = useRef(new Animated.Value(SHEET_HEIGHT)).current;
const backdropOpacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
function handleClose() {
setPassword('');
setPasswordVisible(false);
setFormError(null);
translateY.setValue(SHEET_HEIGHT);
backdropOpacity.setValue(0);
Animated.parallel([
Animated.timing(translateY, {
toValue: 0,
duration: 280,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(backdropOpacity, {
toValue: 1,
duration: 220,
useNativeDriver: true,
}),
]).start();
onClose();
}
}, [visible, translateY, backdropOpacity]);
async function handleSave() {
if (!password.trim()) {
@ -74,52 +44,14 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
setFormError(null);
const result = await connect({ email, password });
if (result.ok) {
onClose();
handleClose();
onSuccess();
} else {
setFormError(result.error ?? t('mail.connect_failed'));
}
}
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose}>
<Animated.View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
opacity: backdropOpacity,
}}
>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: SHEET_HEIGHT,
backgroundColor: '#fff',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
transform: [{ translateY }],
}}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={{ flex: 1 }}
>
{/* Drag-Handle */}
<View style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 4 }}>
<View style={{ width: 36, height: 4, borderRadius: 2, backgroundColor: '#d4d4d4' }} />
</View>
{/* Header */}
const header = (
<View
style={{
flexDirection: 'row',
@ -129,26 +61,34 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
paddingTop: 6,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
borderBottomColor: colors.border,
}}
>
<Pressable onPress={onClose} hitSlop={10}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
{t('common.cancel')}
<Pressable onPress={handleClose} hitSlop={8}>
<Text style={{ fontSize: 15, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('mail.edit_account_cancel')}
</Text>
</Pressable>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('mail.edit_account_title')}
</Text>
<View style={{ width: 60 }} />
</View>
);
<View style={{ flex: 1, padding: 20, gap: 14 }}>
return (
<KeyboardAwareSheet
visible={visible}
onClose={handleClose}
collapsedHeight={COLLAPSED_HEIGHT}
header={header}
>
<View style={{ padding: 20, gap: 14 }}>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#737373',
color: colors.textMuted,
lineHeight: 18,
}}
>
@ -159,13 +99,13 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#f5f5f5',
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
paddingHorizontal: 14,
gap: 10,
}}
>
<Ionicons name="lock-closed-outline" size={16} color="#a3a3a3" />
<Ionicons name="lock-closed-outline" size={16} color={colors.textMuted} />
<TextInput
value={password}
onChangeText={(v) => {
@ -173,7 +113,7 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
setFormError(null);
}}
placeholder={t('mail.app_password_placeholder')}
placeholderTextColor="#a3a3a3"
placeholderTextColor={colors.textMuted}
secureTextEntry={!passwordVisible}
autoCapitalize="none"
autoCorrect={false}
@ -182,7 +122,7 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
paddingVertical: 14,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: '#0a0a0a',
color: colors.text,
}}
/>
<Pressable onPress={() => setPasswordVisible((p) => !p)} hitSlop={8}>
@ -214,7 +154,9 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
color: '#dc2626',
}}
>
{formError ?? connectError}
{formError
? formError
: t(humanizeMailError(connectError))}
</Text>
</View>
)}
@ -227,12 +169,14 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
opacity: pressed ? 0.85 : 1,
})}
>
<View style={{
<View
style={{
paddingVertical: 14,
borderRadius: 12,
backgroundColor: !password.trim() || connecting ? '#bfdbfe' : '#007AFF',
alignItems: 'center',
}}>
}}
>
{connecting ? (
<ActivityIndicator color="#fff" />
) : (
@ -242,11 +186,7 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
)}
</View>
</Pressable>
<View style={{ height: insets.bottom }} />
</View>
</KeyboardAvoidingView>
</Animated.View>
</Modal>
</KeyboardAwareSheet>
);
}

View File

@ -45,15 +45,186 @@ function resolveProviderIcon(provider: string): {
return { icon: 'server', color: '#737373' };
}
function formatRelativeTime(iso: string | null, t: (k: string) => string): string {
if (!iso) return t('mail.account_never_scanned');
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 2) return t('mail.account_just_now');
if (mins < 60) return `${mins} min`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
const STALE_THRESHOLD_MS = 5 * 60 * 1_000;
const IDLE_HEARTBEAT_STALE_MS = STALE_THRESHOLD_MS;
const NO_NEW_MAIL_THRESHOLD_MS = 60 * 60_000;
function formatRelativeAbsolute(ts: Date): string {
const min = Math.floor((Date.now() - ts.getTime()) / 60_000);
const todayStr = new Date().toDateString();
const yesterdayStr = new Date(Date.now() - 86_400_000).toDateString();
const hh = ts.getHours().toString().padStart(2, '0');
const mm = ts.getMinutes().toString().padStart(2, '0');
let dayLabel: string;
if (ts.toDateString() === todayStr) dayLabel = 'heute';
else if (ts.toDateString() === yesterdayStr) dayLabel = 'gestern';
else dayLabel = ts.toLocaleDateString('de', { day: '2-digit', month: '2-digit' });
let rel: string;
if (min < 1) rel = 'gerade eben';
else if (min < 60) rel = `vor ${min} min`;
else if (min < 1440) rel = `vor ${Math.floor(min / 60)}h`;
else rel = `vor ${Math.floor(min / 1440)}d`;
return `${rel} (${dayLabel} ${hh}:${mm})`;
}
function idleHeartbeatAlive(lastIdleHeartbeatAt: string | null | undefined): boolean {
if (!lastIdleHeartbeatAt) return false;
return Date.now() - new Date(lastIdleHeartbeatAt).getTime() < IDLE_HEARTBEAT_STALE_MS;
}
function StatusBadgeRow({
account,
isLegend,
t,
}: {
account: MailAccount;
isLegend: boolean;
t: (k: string, opts?: Record<string, string | number>) => string;
}) {
// Priority 1 — auth / connect error
if (account.lastConnectError) {
const isAuthError =
account.lastConnectError.toLowerCase().includes('invalid credentials') ||
account.lastConnectError.toLowerCase().includes('authentication failed');
const errorLabel = isAuthError ? t('mail.status_auth_error') : t('mail.status_connect_error');
const since = account.lastConnectErrorAt
? formatRelativeAbsolute(new Date(account.lastConnectErrorAt))
: null;
return (
<View style={{ marginTop: 3 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Ionicons name="lock-closed" size={11} color="#dc2626" style={{ marginRight: 4 }} />
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: '#dc2626' }}>
{errorLabel}
</Text>
<Text
style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', marginLeft: 4 }}
>
· {t('mail.status_error_tap_hint')}
</Text>
</View>
{since ? (
<Text
style={{ fontSize: 10, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', marginTop: 1 }}
>
{since}
</Text>
) : null}
</View>
);
}
// Priority 5 — never connected
if (!account.lastScannedAt) {
return (
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 3 }}>
<View style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: '#a3a3a3', marginRight: 5 }} />
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: '#a3a3a3' }}>
{t('mail.status_waiting_first_connect')}
</Text>
</View>
);
}
const heartbeatAlive = idleHeartbeatAlive(account.lastIdleHeartbeatAt);
const lastScannedTs = new Date(account.lastScannedAt);
const scannedAgo = Date.now() - lastScannedTs.getTime();
const scannedRelAbs = formatRelativeAbsolute(lastScannedTs);
// Priority 4 — stale: heartbeat missing/expired AND scan is old
if (!heartbeatAlive && scannedAgo > STALE_THRESHOLD_MS) {
return (
<View style={{ marginTop: 3 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: '#d97706', marginRight: 5 }}
/>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: '#d97706' }}>
{t('mail.status_stale')}
</Text>
</View>
<Text
style={{ fontSize: 10, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', marginTop: 1 }}
numberOfLines={1}
>
{t('mail.status_stale_last_scan', { rel: scannedRelAbs })}
</Text>
</View>
);
}
// Priority 2 + 3 — heartbeat alive (or scan recent enough for pre-migration backend)
if (heartbeatAlive) {
const heartbeatTs = new Date(account.lastIdleHeartbeatAt!);
const heartbeatMin = Math.floor((Date.now() - heartbeatTs.getTime()) / 60_000);
const idleSince = heartbeatMin < 1 ? 'gerade eben' : `${heartbeatMin} min`;
if (scannedAgo > NO_NEW_MAIL_THRESHOLD_MS) {
// Priority 3 — connected but no new mail for >1h
return (
<View style={{ marginTop: 3 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: '#16a34a', marginRight: 5 }}
/>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: '#16a34a' }}>
{isLegend ? t('mail.live') : t('mail.account_active')}
</Text>
</View>
<Text
style={{ fontSize: 10, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', marginTop: 1 }}
numberOfLines={1}
>
{t('mail.status_live_no_new_mail', { rel: scannedRelAbs })}
</Text>
</View>
);
}
// Priority 2 — live + heartbeat recent + scan recent
return (
<View style={{ marginTop: 3 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: '#16a34a', marginRight: 5 }}
/>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: '#16a34a' }}>
{isLegend ? t('mail.live') : t('mail.account_active')}
</Text>
</View>
<Text
style={{ fontSize: 10, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', marginTop: 1 }}
numberOfLines={1}
>
{t('mail.status_live_idle', { rel: idleSince })}
</Text>
</View>
);
}
// Fallback — scan recent, backend without heartbeat field
return (
<View style={{ marginTop: 3 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: '#16a34a', marginRight: 5 }}
/>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: '#16a34a' }}>
{isLegend ? t('mail.live') : t('mail.account_active')}
</Text>
</View>
<Text
style={{ fontSize: 10, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', marginTop: 1 }}
numberOfLines={1}
>
{scannedRelAbs}
</Text>
</View>
);
}
const INTERVAL_OPTIONS_BY_PLAN: Record<'free' | 'pro' | 'legend', number[]> = {
@ -62,7 +233,6 @@ const INTERVAL_OPTIONS_BY_PLAN: Record<'free' | 'pro' | 'legend', number[]> = {
legend: [1, 4, 8],
};
// Solid styles outside of render — no gap, no callback layout.
const HEADER_ROW = {
flexDirection: 'row' as const,
alignItems: 'center' as const,
@ -99,6 +269,10 @@ export function MailAccountCard({
const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan];
function handleToggle() {
if (account.lastConnectError) {
setEditVisible(true);
return;
}
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
onToggle();
}
@ -115,11 +289,11 @@ export function MailAccountCard({
backgroundColor: '#fff',
borderRadius: 16,
borderWidth: 1,
borderColor: '#e5e5e5',
borderColor: account.lastConnectError ? '#fecaca' : '#e5e5e5',
overflow: 'hidden',
}}
>
{/* ── Header ── */}
{/* Header */}
<Pressable onPress={handleToggle} android_ripple={{ color: '#f5f5f5' }}>
<View style={HEADER_ROW}>
<View
@ -143,37 +317,7 @@ export function MailAccountCard({
>
{account.email}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 3 }}>
<View
style={{
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: account.isActive ? '#16a34a' : '#dc2626',
marginRight: 5,
}}
/>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_600SemiBold',
color: account.isActive ? '#16a34a' : '#dc2626',
marginRight: 8,
}}
>
{account.isActive
? isLegend
? t('mail.live')
: t('mail.account_active')
: t('mail.account_inactive')}
</Text>
<Text
style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#a3a3a3' }}
numberOfLines={1}
>
· {formatRelativeTime(account.lastScannedAt, t)}
</Text>
</View>
<StatusBadgeRow account={account} isLegend={isLegend} t={t} />
</View>
<Ionicons
@ -184,10 +328,9 @@ export function MailAccountCard({
</View>
</Pressable>
{/* ── Body ── */}
{/* Body */}
{expanded && (
<View style={{ borderTopWidth: 1, borderTopColor: '#f5f5f5', padding: 14 }}>
{/* Big stat: Blocked */}
<View
style={{
flexDirection: 'row',
@ -201,9 +344,7 @@ export function MailAccountCard({
>
<Ionicons name="shield-checkmark" size={20} color="#dc2626" style={{ marginRight: 10 }} />
<View style={{ flex: 1 }}>
<Text
style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: '#dc2626' }}
>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: '#dc2626' }}>
{t('mail.account_stat_blocked')}
</Text>
<Text
@ -217,16 +358,13 @@ export function MailAccountCard({
{account.totalBlocked.toLocaleString()}
</Text>
</View>
<Text
style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#737373' }}
>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#737373' }}>
{t('mail.account_of_scanned', {
scanned: account.totalScanned.toLocaleString(),
})}
</Text>
</View>
{/* Scan Mode */}
{isLegend ? (
<View
style={{
@ -312,7 +450,6 @@ export function MailAccountCard({
</View>
)}
{/* Action Row */}
<View style={{ flexDirection: 'row' }}>
<Pressable
onPress={() => setEditVisible(true)}

View File

@ -1,6 +1,7 @@
import { Pressable, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
type Props = {
onConnectPress: () => void;
@ -12,14 +13,15 @@ type Props = {
*/
export function MailEmptyState({ onConnectPress }: Props) {
const { t } = useTranslation();
const colors = useColors();
return (
<View
style={{
backgroundColor: '#fff',
backgroundColor: colors.bg,
borderRadius: 20,
borderWidth: 1,
borderColor: '#e5e5e5',
borderColor: colors.border,
padding: 28,
alignItems: 'center',
}}
@ -45,7 +47,7 @@ export function MailEmptyState({ onConnectPress }: Props) {
style={{
fontSize: 17,
fontFamily: 'Nunito_700Bold',
color: '#0a0a0a',
color: colors.text,
textAlign: 'center',
marginBottom: 8,
}}
@ -57,7 +59,7 @@ export function MailEmptyState({ onConnectPress }: Props) {
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#737373',
color: colors.textMuted,
textAlign: 'center',
lineHeight: 19,
marginBottom: 20,
@ -71,7 +73,7 @@ export function MailEmptyState({ onConnectPress }: Props) {
{(['privacy_1', 'privacy_2', 'privacy_3'] as const).map((key) => (
<View key={key} style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Ionicons name="checkmark-circle" size={15} color="#16a34a" />
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#525252', flex: 1 }}>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted, flex: 1 }}>
{t(`mail.${key}`)}
</Text>
</View>

Some files were not shown because too many files have changed in this diff Show More