Release-Vorbereitung: Proxy, Kostenoptimierung, Freemium, Store-Assets

- Backend-Proxy (Cloudflare Worker in proxy/): API-Key serverseitig,
  Modell-Whitelist, max_tokens-Deckel, Tageslimits pro Gerät und IP
- Modell-Split: Analyse auf Sonnet 5, Übersetzung/Antwort auf Haiku 4.5
- Bild-Downscaling auf 2000px vor dem Upload (ScanScreen)
- Expo SDK 56 -> 57 Upgrade
- Freemium: 3 Gratis-Analysen mit lokalem Zähler und Paywall-Hinweis
- Einstellungen: API-Key-Feld nur noch im Dev-Modus (NUTZT_PROXY)
- Build-Setup: Bundle-IDs, eas.json, Splash-Screen-Plugin
- Tests: jest-expo + 17 Tests für die Ampel-/Fristlogik
- Rechtliches: Datenschutzerklärung (Entwurf), Webseite (webseite/,
  live auf behoerdenklar.pages.dev), Store-Texte (store/)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 14:14:00 +02:00
parent 88c49e1b54
commit 9dc1d8d87b
26 changed files with 6587 additions and 480 deletions

4
.gitignore vendored
View File

@@ -33,6 +33,10 @@ yarn-error.*
# local env files
.env*.local
# Cloudflare Worker (Proxy)
.wrangler/
.dev.vars
# typescript
*.tsbuildinfo

View File

@@ -36,19 +36,19 @@ App in Expo Go (oder Development Build) öffnen, dann:
## Produktion: Backend-Proxy statt API-Key in der App
Für Endkunden darf kein API-Key in der App stecken. Vorbereitet in
`src/services/claudeClient.ts`:
Für Endkunden darf kein API-Key in der App stecken. Der fertige Proxy
(Cloudflare Worker) liegt in [`proxy/`](proxy/README.md) — er hält den Key
serverseitig, erlaubt nur die App-Modelle, deckelt `max_tokens` und setzt ein
Tageslimit pro Gerät durch.
Nach dem Deployment (Anleitung: `proxy/README.md`) in
`src/services/claudeClient.ts` die Worker-URL eintragen:
```ts
const PROXY_URL: string | null = null; // -> eigene Server-URL eintragen
const PROXY_URL: string | null = 'https://behoerdenklar-proxy.<account>.workers.dev';
```
Der Proxy (z. B. Cloudflare Worker / Vercel Function) muss nur:
1. den Request-Body unverändert an `https://api.anthropic.com/v1/messages` weiterleiten,
2. dabei serverseitig `x-api-key` + `anthropic-version: 2023-06-01` setzen,
3. die Antwort unverändert zurückgeben.
Abrechnung der Endkunden dann per Abo/In-App-Kauf.
Abrechnung der Endkunden dann per Abo/In-App-Kauf (Abo-Prüfung im Proxy ergänzen).
## Architektur

View File

@@ -7,9 +7,11 @@
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"ios": {
"supportsTablet": true
"supportsTablet": true,
"bundleIdentifier": "de.behoerdenklar.app"
},
"android": {
"package": "de.behoerdenklar.app",
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
@@ -38,7 +40,15 @@
}
],
"expo-notifications",
"expo-status-bar"
"expo-status-bar",
[
"expo-splash-screen",
{
"image": "./assets/splash-icon.png",
"backgroundColor": "#E6F4FE",
"imageWidth": 200
}
]
]
}
}

21
eas.json Normal file
View File

@@ -0,0 +1,21 @@
{
"cli": {
"version": ">= 16.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}

4225
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,34 +6,43 @@
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/native": "^7.3.8",
"@react-navigation/native-stack": "^7.17.10",
"expo": "^56.0.15",
"expo-calendar": "~56.0.9",
"expo-camera": "~56.0.8",
"expo-document-picker": "~56.0.4",
"expo-file-system": "~56.0.8",
"expo-image-picker": "~56.0.20",
"expo-network": "~56.0.5",
"expo-notifications": "~56.0.20",
"expo-print": "~56.0.4",
"expo-secure-store": "~56.0.4",
"expo-sharing": "~56.0.21",
"expo-speech": "~56.0.3",
"expo-status-bar": "~56.0.4",
"expo": "^57.0.0",
"expo-calendar": "~57.0.0",
"expo-camera": "~57.0.1",
"expo-document-picker": "~57.0.0",
"expo-file-system": "~57.0.0",
"expo-image-manipulator": "~57.0.2",
"expo-image-picker": "~57.0.2",
"expo-network": "~57.0.0",
"expo-notifications": "~57.0.3",
"expo-print": "~57.0.0",
"expo-secure-store": "~57.0.0",
"expo-sharing": "~57.0.3",
"expo-speech": "~57.0.0",
"expo-splash-screen": "~57.0.2",
"expo-status-bar": "~57.0.0",
"react": "19.2.3",
"react-native": "0.85.3",
"react-native": "0.86.0",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/jest": "~29.5.14",
"@types/react": "~19.2.2",
"jest": "~29.7.0",
"jest-expo": "~57.0.1",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
"web": "expo start --web",
"test": "jest"
},
"jest": {
"preset": "jest-expo"
},
"private": true
}

61
proxy/README.md Normal file
View File

@@ -0,0 +1,61 @@
# BehördenKlar Backend-Proxy
Cloudflare Worker, der den Anthropic-API-Key serverseitig hält. Die App schickt
ihre Claude-Anfragen an diesen Worker statt direkt an Anthropic — kein API-Key
in der App, Missbrauchsschutz inklusive.
## Was der Worker macht
1. Prüft die anonyme Geräte-ID (`x-geraete-id`-Header) und setzt ein
**Tageslimit pro Gerät** durch (Standard: 20 Anfragen, siehe `wrangler.toml`)
2. Erlaubt nur die **Modelle, die die App nutzt**, und deckelt `max_tokens`
3. Leitet den Request-Body unverändert an `api.anthropic.com/v1/messages`
weiter und setzt dabei serverseitig `x-api-key` + `anthropic-version`
4. Gibt die Anthropic-Antwort unverändert zurück
## Deployment (einmalig)
```bash
cd proxy
npm install
# Bei Cloudflare anmelden (kostenloser Account reicht für den Start)
npx wrangler login
# KV-Namespace für das Rate-Limit anlegen …
npx wrangler kv namespace create RATE_LIMIT
# … und die ausgegebene ID in wrangler.toml bei [[kv_namespaces]] eintragen
# Anthropic-API-Key als Secret hinterlegen (von console.anthropic.com)
npx wrangler secret put ANTHROPIC_API_KEY
# Deployen
npm run deploy
```
Der Deploy gibt eine URL aus, z. B. `https://behoerdenklar-proxy.<account>.workers.dev`.
## App umstellen
In [`src/services/claudeClient.ts`](../src/services/claudeClient.ts) die URL eintragen:
```ts
const PROXY_URL: string | null = 'https://behoerdenklar-proxy.<account>.workers.dev';
```
Fertig — die App braucht dann keinen API-Key mehr in den Einstellungen.
## Lokal testen
```bash
npm run dev # startet den Worker auf http://localhost:8787
```
Dann in `claudeClient.ts` vorübergehend `PROXY_URL = 'http://localhost:8787'`
setzen (im Simulator; auf echtem Gerät die LAN-IP des Rechners verwenden).
## Später ergänzen (wenn Abo/IAP kommt)
- Abo-Prüfung: RevenueCat-Webhook oder Receipt-Validierung vor dem Weiterleiten
- Tageslimit je nach Abo-Stufe (Free: 3, Abo: 20+)
- Die Geräte-ID durch eine echte Nutzer-ID ersetzen

1576
proxy/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
proxy/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "behoerdenklar-proxy",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20260708.1",
"typescript": "~5.9.0",
"wrangler": "^4.0.0"
}
}

128
proxy/src/index.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* BehördenKlar Backend-Proxy — Cloudflare Worker
*
* Hält den Anthropic-API-Key serverseitig, damit kein Key in der App steckt.
* Die App schickt denselben Request-Body wie an die Anthropic Messages API;
* der Worker prüft, limitiert und leitet weiter — die Antwort geht
* unverändert zurück (die App parst sie wie eine direkte Anthropic-Antwort).
*
* Schutzmaßnahmen gegen Missbrauch des Endpunkts:
* - Nur POST, nur erlaubte Modelle, max_tokens gedeckelt
* - Tageslimit pro Geräte-ID (KV-basiert, weiches Limit)
*
* Deployment: siehe proxy/README.md
*/
interface Env {
/** Geheimnis: `wrangler secret put ANTHROPIC_API_KEY` */
ANTHROPIC_API_KEY: string;
/** KV-Namespace für das Rate-Limit (Binding in wrangler.toml) */
RATE_LIMIT: KVNamespace;
/** Erlaubte Anfragen pro Gerät und Tag (in wrangler.toml unter [vars]) */
TAGES_LIMIT: string;
/** Erlaubte Anfragen pro IP-Adresse und Tag (in wrangler.toml unter [vars]) */
TAGES_LIMIT_IP: string;
}
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
const ANTHROPIC_VERSION = '2023-06-01';
/** Nur Modelle, die die App tatsächlich nutzt — verhindert, dass ein
* extrahierter Endpunkt als Gratis-Zugang für teure Modelle dient. */
const ERLAUBTE_MODELLE = new Set([
'claude-sonnet-5', // Brief-Analyse (Vision)
'claude-haiku-4-5', // Übersetzung & Antwort-Entwürfe
]);
const MAX_TOKENS_OBERGRENZE = 16000;
/** Fehlerantwort im Anthropic-Format, damit die App sie normal verarbeitet. */
function fehler(status: number, typ: string, meldung: string): Response {
return Response.json(
{ type: 'error', error: { type: typ, message: meldung } },
{ status }
);
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') {
return fehler(405, 'invalid_request_error', 'Nur POST erlaubt.');
}
// Geräte-ID der App (anonym, dient nur dem Tageslimit)
const geraeteId = request.headers.get('x-geraete-id');
if (!geraeteId || !/^g_[a-z0-9]{24}$/.test(geraeteId)) {
return fehler(400, 'invalid_request_error', 'Fehlende oder ungültige Geräte-ID.');
}
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return fehler(400, 'invalid_request_error', 'Request-Body ist kein gültiges JSON.');
}
if (typeof body.model !== 'string' || !ERLAUBTE_MODELLE.has(body.model)) {
return fehler(400, 'invalid_request_error', 'Dieses Modell ist nicht erlaubt.');
}
// max_tokens deckeln statt ablehnen — schützt vor Kosten-Missbrauch
if (typeof body.max_tokens !== 'number' || body.max_tokens > MAX_TOKENS_OBERGRENZE) {
body.max_tokens = MAX_TOKENS_OBERGRENZE;
}
// Die App streamt nicht; Streaming würde das Durchreichen verkomplizieren
if (body.stream) {
return fehler(400, 'invalid_request_error', 'Streaming wird nicht unterstützt.');
}
// Tageslimits prüfen (weiche Limits: KV ist eventually consistent,
// parallele Anfragen können das Limit minimal überschreiten — okay).
// Zwei Ebenen: pro Gerät (normale Nutzung) und pro IP-Adresse (dämmt
// Angreifer ein, die sich beliebig neue Geräte-IDs ausdenken). Das
// IP-Limit ist bewusst höher, weil sich viele Nutzer eine IP teilen
// können (Familien-WLAN, Mobilfunk/CGNAT).
const heute = new Date().toISOString().slice(0, 10);
const ip = request.headers.get('cf-connecting-ip') ?? 'unbekannt';
const kvKey = `rl:${geraeteId}:${heute}`;
const kvKeyIp = `rlip:${ip}:${heute}`;
const limit = parseInt(env.TAGES_LIMIT || '20', 10);
const limitIp = parseInt(env.TAGES_LIMIT_IP || '100', 10);
const [bisher, bisherIp] = (
await Promise.all([env.RATE_LIMIT.get(kvKey), env.RATE_LIMIT.get(kvKeyIp)])
).map((wert) => parseInt(wert ?? '0', 10));
if (bisher >= limit || bisherIp >= limitIp) {
return fehler(
429,
'rate_limit_error',
'Tageslimit erreicht. Bitte versuchen Sie es morgen erneut.'
);
}
// An Anthropic weiterleiten — Header werden frisch gebaut, nichts vom
// Client wird durchgereicht (außer dem geprüften Body)
const antwort = await fetch(ANTHROPIC_URL, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': env.ANTHROPIC_API_KEY,
'anthropic-version': ANTHROPIC_VERSION,
},
body: JSON.stringify(body),
});
// Nur zählen, wenn die Anfrage Anthropic erreicht hat (5xx kostet kein Kontingent)
if (antwort.status < 500) {
await Promise.all([
env.RATE_LIMIT.put(kvKey, String(bisher + 1), { expirationTtl: 60 * 60 * 48 }),
env.RATE_LIMIT.put(kvKeyIp, String(bisherIp + 1), { expirationTtl: 60 * 60 * 48 }),
]);
}
return new Response(antwort.body, {
status: antwort.status,
headers: {
'content-type': antwort.headers.get('content-type') ?? 'application/json',
},
});
},
} satisfies ExportedHandler<Env>;

12
proxy/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"types": ["@cloudflare/workers-types"]
},
"include": ["src"]
}

14
proxy/wrangler.toml Normal file
View File

@@ -0,0 +1,14 @@
name = "behoerdenklar-proxy"
main = "src/index.ts"
compatibility_date = "2026-07-01"
# Erlaubte Anfragen pro Tag: pro Gerät (normale Nutzung) und pro IP-Adresse
# (dämmt Angreifer ein, die Geräte-IDs fälschen; höher, weil sich viele
# Nutzer eine IP teilen können)
[vars]
TAGES_LIMIT = "20"
TAGES_LIMIT_IP = "100"
[[kv_namespaces]]
binding = "RATE_LIMIT"
id = "7577adef8ceb4ed6bfaeef81a1d496af"

View File

@@ -0,0 +1,104 @@
# Datenschutzerklärung — BehördenKlar
> **⚠️ ENTWURF — vor Veröffentlichung prüfen (lassen).**
> Dieser Text ist eine sorgfältige Vorlage, aber keine Rechtsberatung.
> Vor dem App-Store-Release von einer Anwältin/einem Anwalt für Datenschutzrecht
> oder einem Dienst wie eRecht24/activeMind prüfen lassen. Alle Stellen in
> [ECKIGEN KLAMMERN] müssen ausgefüllt werden.
**Stand: 14.07.2026**
## 1. Verantwortlicher
Justin Klein
Am Schwimmbad 10
67722 Winnweiler
E-Mail: behoerdenbriefhelfer@gmail.com
## 2. Was macht die App?
BehördenKlar hilft Ihnen, Behördenbriefe zu verstehen: Sie fotografieren einen
Brief, und eine Künstliche Intelligenz (KI) erklärt den Inhalt in einfacher
Sprache, findet Fristen und Termine und hilft beim Antworten.
## 3. Welche Daten werden verarbeitet?
### a) Brieffotos und PDF-Dateien (nur mit Ihrer Einwilligung)
Wenn Sie einen Brief scannen, wird das Foto bzw. die PDF-Datei zur Analyse
übertragen an:
1. **Unseren Server** (Cloudflare Workers, Anbieter: Cloudflare, Inc., USA) —
er leitet die Anfrage nur weiter und **speichert den Briefinhalt nicht**.
2. **Anthropic PBC (USA)** — den Betreiber des KI-Modells „Claude". Anthropic
verarbeitet die Daten ausschließlich zur Erstellung der Analyse, speichert
sie **maximal 30 Tage** (zur Missbrauchserkennung) und verwendet sie
**nicht zum Training** der KI.
**Wichtig:** Behördenbriefe können sehr persönliche und besondere Kategorien
von Daten enthalten (z. B. Angaben zu Sozialleistungen, Gesundheit oder
finanzieller Situation). Die Analyse erfolgt deshalb nur nach Ihrer
**ausdrücklichen Einwilligung**, die Sie vor dem ersten Scan erteilen
(Art. 6 Abs. 1 lit. a, Art. 9 Abs. 2 lit. a DSGVO). Sie können die
Einwilligung jederzeit widerrufen, indem Sie in den Einstellungen
„Alle Daten löschen" wählen.
### b) Analyse-Ergebnisse, Übersetzungen, Antwort-Entwürfe
Alle Ergebnisse werden **ausschließlich lokal auf Ihrem Gerät** gespeichert.
Wir betreiben keine Nutzerkonten und keine Cloud-Speicherung Ihrer Briefe.
### c) Anonyme Geräte-Kennung
Die App erzeugt eine zufällige, anonyme Kennung (z. B. „g_a1b2…"). Sie enthält
keine Angaben über Sie oder Ihr Gerät und dient ausschließlich dazu, ein
tägliches Nutzungslimit pro Installation durchzusetzen (Schutz vor Missbrauch,
Art. 6 Abs. 1 lit. f DSGVO). Sie wird zusammen mit einem Tageszähler für
maximal 48 Stunden auf unserem Server gespeichert.
### d) Kalender, Erinnerungen, Vorlesen
Kalendereinträge und Frist-Erinnerungen werden nur auf Ihrem Gerät angelegt
(mit Ihrer Erlaubnis über die iOS-/Android-Berechtigungen). Die
Vorlese-Funktion nutzt die Sprachausgabe Ihres Geräts. Dabei werden keine
Daten an uns übertragen.
## 4. Übermittlung in die USA
Anthropic PBC und Cloudflare, Inc. haben ihren Sitz in den USA. Die
Übermittlung stützt sich auf [ZU PRÜFEN: EU-U.S. Data Privacy Framework —
Zertifizierung von Anthropic und Cloudflare verifizieren — und/oder
EU-Standardvertragsklauseln].
## 5. Speicherdauer
- Brieffotos bei Anthropic: maximal 30 Tage, danach automatische Löschung
- Anonyme Geräte-Kennung + Tageszähler auf unserem Server: maximal 48 Stunden
- Daten auf Ihrem Gerät: bis Sie sie löschen („Alle Daten löschen" in den
Einstellungen oder Deinstallation der App)
## 6. Ihre Rechte
Sie haben nach der DSGVO das Recht auf Auskunft (Art. 15), Berichtigung
(Art. 16), Löschung (Art. 17), Einschränkung der Verarbeitung (Art. 18),
Datenübertragbarkeit (Art. 20) und Widerspruch (Art. 21). Außerdem können Sie
sich bei einer Datenschutz-Aufsichtsbehörde beschweren (Art. 77 DSGVO),
z. B. beim Landesbeauftragten für den Datenschutz und die Informationsfreiheit
Rheinland-Pfalz.
Da wir keine Nutzerkonten führen, liegen die meisten Daten nur auf Ihrem
Gerät — dort können Sie sie selbst einsehen und löschen. Für Anfragen zu den
serverseitig verarbeiteten Daten wenden Sie sich an:
behoerdenbriefhelfer@gmail.com
## 7. Keine Rechtsberatung
BehördenKlar erklärt Briefe verständlich, ersetzt aber keine Rechtsberatung.
Bei rechtlich wichtigen Entscheidungen (z. B. Widerspruch) wenden Sie sich an
eine Beratungsstelle oder eine Anwältin/einen Anwalt.
## 8. Änderungen
Wir passen diese Datenschutzerklärung an, wenn sich die App oder die
Rechtslage ändert. Die aktuelle Fassung finden Sie immer in der App unter
Einstellungen → Datenschutz.

View File

@@ -1,6 +1,7 @@
/**
* Einstellungen: API-Schlüssel (Dev-Modus), Datenschutz-Info,
* "Alle Daten löschen".
* Einstellungen: Datenschutz-Info und "Alle Daten löschen".
* Das API-Schlüssel-Feld erscheint nur im Entwickler-Modus (ohne Proxy) —
* im Produktionsmodus läuft der KI-Zugang über den Backend-Proxy.
*/
import React, { useEffect, useState } from 'react';
import { Alert, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
@@ -8,6 +9,7 @@ import * as Notifications from 'expo-notifications';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { RootStackParamList } from '../types';
import { holeApiKey, speichereApiKey, loescheAlleDaten } from '../services/storage';
import { NUTZT_PROXY } from '../services/claudeClient';
import { useAppStore } from '../store/useAppStore';
import { GrossButton } from '../components/GrossButton';
import { farben, schrift, abstand } from '../theme';
@@ -35,7 +37,7 @@ export function EinstellungenScreen({ navigation }: Props) {
const allesLoeschen = () => {
Alert.alert(
'Wirklich ALLE Daten löschen?',
'Alle gescannten Briefe, Übersetzungen, Entwürfe, der API-Schlüssel und die Einwilligung werden dauerhaft gelöscht. Das kann nicht rückgängig gemacht werden.',
'Alle gescannten Briefe, Übersetzungen, Entwürfe und die Einwilligung werden dauerhaft gelöscht. Das kann nicht rückgängig gemacht werden.',
[
{ text: 'Abbrechen', style: 'cancel' },
{
@@ -57,6 +59,8 @@ export function EinstellungenScreen({ navigation }: Props) {
return (
<ScrollView style={styles.container} contentContainerStyle={{ padding: abstand.m }}>
{!NUTZT_PROXY && (
<>
<Text style={styles.abschnittTitel}>KI-Zugang (API-Schlüssel)</Text>
<Text style={styles.text}>
Status:{' '}
@@ -87,12 +91,15 @@ export function EinstellungenScreen({ navigation }: Props) {
/>
<View style={styles.trenner} />
</>
)}
<Text style={styles.abschnittTitel}>Datenschutz</Text>
<View style={styles.karte}>
<Text style={styles.text}>
Brieffotos werden nur zur Analyse an Anthropic gesendet und dort
nicht dauerhaft gespeichert.{'\n\n'}
Brieffotos werden nur zur Analyse an den KI-Dienst Anthropic (USA)
gesendet und dort nach spätestens 30 Tagen gelöscht. Sie werden nicht
zum Training der KI verwendet.{'\n\n'}
Alle Ergebnisse liegen ausschließlich auf Ihrem Gerät.{'\n\n'}
Diese App ersetzt keine Rechtsberatung.
</Text>

View File

@@ -2,15 +2,18 @@
* Brief erfassen: Foto aufnehmen, aus Galerie wählen oder PDF hochladen.
* Danach läuft die KI-Analyse; bei Erfolg geht es direkt zum Ergebnis.
*/
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { ActivityIndicator, Alert, StyleSheet, Text, View } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import * as ImagePicker from 'expo-image-picker';
import * as DocumentPicker from 'expo-document-picker';
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator';
import { File } from 'expo-file-system';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { RootStackParamList, BriefEintrag } from '../types';
import { analysiereBrief } from '../services/analyse';
import { ClaudeFehler } from '../services/claudeClient';
import { ClaudeFehler, NUTZT_PROXY } from '../services/claudeClient';
import { GRATIS_ANALYSEN, holeAnzahlAnalysen, zaehleAnalyse } from '../services/storage';
import { planeErinnerungen } from '../services/erinnerungen';
import { useAppStore, neueId } from '../store/useAppStore';
import { GrossButton } from '../components/GrossButton';
@@ -18,15 +21,68 @@ import { farben, schrift, abstand } from '../theme';
type Props = NativeStackScreenProps<RootStackParamList, 'Scan'>;
/**
* Maximale Kantenlänge fürs KI-Bild. Ein A4-Brief bleibt bei 2000px gut
* lesbar (~170 dpi); moderne Handyfotos (4000px+) würden unverkleinert
* bis zu 3x mehr Bild-Token kosten.
*/
const MAX_BILD_KANTE = 2000;
/**
* Verkleinert ein Foto auf max. MAX_BILD_KANTE (lange Kante, Seitenverhältnis
* bleibt erhalten), komprimiert als JPEG und liefert base64 für die Analyse.
*/
async function bildFuerAnalyse(asset: ImagePicker.ImagePickerAsset): Promise<string> {
const kontext = ImageManipulator.manipulate(asset.uri);
if (Math.max(asset.width, asset.height) > MAX_BILD_KANTE) {
kontext.resize(
asset.width >= asset.height
? { width: MAX_BILD_KANTE }
: { height: MAX_BILD_KANTE }
);
}
const bild = await kontext.renderAsync();
const ergebnis = await bild.saveAsync({
format: SaveFormat.JPEG,
compress: 0.6,
base64: true,
});
return ergebnis.base64!;
}
export function ScanScreen({ navigation }: Props) {
const addBrief = useAppStore((s) => s.addBrief);
const [laedt, setLaedt] = useState(false);
const [verbraucht, setVerbraucht] = useState(0);
// Zähler bei jedem Screen-Besuch frisch laden
useFocusEffect(
useCallback(() => {
holeAnzahlAnalysen().then(setVerbraucht);
}, [])
);
// Freemium gilt nur im Produktionsmodus (Proxy). Im Dev-Modus mit
// eigenem API-Key zahlt der Entwickler selbst — kein Limit nötig.
const uebrig = GRATIS_ANALYSEN - verbraucht;
const kontingentLeer = NUTZT_PROXY && uebrig <= 0;
/** Prüft das Gratis-Kontingent; false = Analyse nicht starten. */
const pruefeKontingent = (): boolean => {
if (!kontingentLeer) return true;
Alert.alert(
'Gratis-Analysen aufgebraucht',
`Sie haben Ihre ${GRATIS_ANALYSEN} kostenlosen Brief-Analysen genutzt. Bald können Sie hier ein Abo abschließen, um weiter Briefe zu analysieren.`
);
return false;
};
/** Gemeinsamer Abschluss: analysieren, speichern, Erinnerungen, weiter. */
const verarbeite = async (base64: string, mimeType: string) => {
setLaedt(true);
try {
const analyse = await analysiereBrief(base64, mimeType);
await zaehleAnalyse();
const brief: BriefEintrag = {
id: neueId(),
erstelltAm: new Date().toISOString(),
@@ -49,6 +105,7 @@ export function ScanScreen({ navigation }: Props) {
};
const fotoAufnehmen = async () => {
if (!pruefeKontingent()) return;
const { granted } = await ImagePicker.requestCameraPermissionsAsync();
if (!granted) {
Alert.alert('Kamera-Zugriff nötig', 'Bitte erlauben Sie den Kamera-Zugriff in den Geräte-Einstellungen.');
@@ -56,16 +113,17 @@ export function ScanScreen({ navigation }: Props) {
}
const ergebnis = await ImagePicker.launchCameraAsync({
mediaTypes: 'images',
quality: 0.6, // Kompromiss: lesbar für die KI, aber kleine Datenmenge
base64: true,
// Volle Qualität vom Picker — verkleinert & komprimiert wird einmal
// zentral in bildFuerAnalyse (doppelte JPEG-Kompression vermeiden)
quality: 1,
});
if (!ergebnis.canceled && ergebnis.assets[0]?.base64) {
// base64 der ImagePicker-Assets ist laut Doku immer JPEG
await verarbeite(ergebnis.assets[0].base64, 'image/jpeg');
if (!ergebnis.canceled && ergebnis.assets[0]) {
await verarbeite(await bildFuerAnalyse(ergebnis.assets[0]), 'image/jpeg');
}
};
const ausGalerie = async () => {
if (!pruefeKontingent()) return;
const { granted } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!granted) {
Alert.alert('Foto-Zugriff nötig', 'Bitte erlauben Sie den Zugriff auf Ihre Fotos in den Geräte-Einstellungen.');
@@ -73,15 +131,15 @@ export function ScanScreen({ navigation }: Props) {
}
const ergebnis = await ImagePicker.launchImageLibraryAsync({
mediaTypes: 'images',
quality: 0.6,
base64: true,
quality: 1,
});
if (!ergebnis.canceled && ergebnis.assets[0]?.base64) {
await verarbeite(ergebnis.assets[0].base64, 'image/jpeg');
if (!ergebnis.canceled && ergebnis.assets[0]) {
await verarbeite(await bildFuerAnalyse(ergebnis.assets[0]), 'image/jpeg');
}
};
const pdfWaehlen = async () => {
if (!pruefeKontingent()) return;
const ergebnis = await DocumentPicker.getDocumentAsync({
type: 'application/pdf',
copyToCacheDirectory: true,
@@ -118,6 +176,13 @@ export function ScanScreen({ navigation }: Props) {
<GrossButton titel="Foto aufnehmen" symbol="📷" onPress={fotoAufnehmen} />
<GrossButton titel="Aus Galerie wählen" symbol="🖼️" variante="sekundaer" onPress={ausGalerie} />
<GrossButton titel="PDF hochladen" symbol="📄" variante="sekundaer" onPress={pdfWaehlen} />
{NUTZT_PROXY && (
<Text style={styles.kontingent}>
{kontingentLeer
? 'Gratis-Analysen aufgebraucht — Abo folgt in Kürze'
: `Noch ${uebrig} von ${GRATIS_ANALYSEN} kostenlosen Analysen`}
</Text>
)}
</View>
);
}
@@ -145,6 +210,12 @@ const styles = StyleSheet.create({
padding: abstand.xl,
gap: abstand.m,
},
kontingent: {
fontSize: schrift.klein,
color: farben.textSekundaer,
textAlign: 'center',
marginTop: abstand.m,
},
ladeText: { fontSize: schrift.gross, fontWeight: '700', color: farben.text },
ladeHinweis: {
fontSize: schrift.basis,

View File

@@ -4,7 +4,7 @@
* Sprache, Fachbegriffe, Fristen, Termine, Checkliste und Antwort-Optionen.
*/
import { BriefAnalyse } from '../types';
import { claudeJsonAufruf, ContentBlock } from './claudeClient';
import { claudeJsonAufruf, ContentBlock, MODELL_ANALYSE } from './claudeClient';
/**
* JSON-Schema für die Analyse. Die Claude API erzwingt dieses Schema
@@ -159,6 +159,7 @@ export async function analysiereBrief(
};
return claudeJsonAufruf<BriefAnalyse>({
modell: MODELL_ANALYSE,
system: SYSTEM_PROMPT,
schema: ANALYSE_SCHEMA,
messages: [

View File

@@ -4,7 +4,7 @@
* Entwurf danach frei bearbeiten und als PDF exportieren.
*/
import { AntwortTyp, ANTWORT_TYP_LABEL, BriefAnalyse } from '../types';
import { claudeJsonAufruf } from './claudeClient';
import { claudeJsonAufruf, MODELL_EINFACH } from './claudeClient';
const ANTWORT_SCHEMA = {
type: 'object',
@@ -36,6 +36,8 @@ export async function generiereAntwort(
hinweise: string
): Promise<AntwortEntwurf> {
return claudeJsonAufruf<AntwortEntwurf>({
modell: MODELL_EINFACH,
maxTokens: 4000,
system: `Du schreibst formelle, höfliche Antwortbriefe an deutsche Behörden im Namen von Privatpersonen. Regeln:
- Korrektes, formelles Deutsch (der Empfänger ist eine Behörde — hier KEINE einfache Sprache).
- Kurz und präzise, keine Floskeln über das Nötige hinaus.

View File

@@ -13,16 +13,32 @@
* deshalb hier bewusst rohes fetch gegen die Messages API.
*/
import * as Network from 'expo-network';
import { holeApiKey } from './storage';
import { holeApiKey, holeGeraeteId } from './storage';
/** Für Produktion hier die eigene Server-URL eintragen, z. B. 'https://api.meine-app.de/claude'. */
const PROXY_URL: string | null = null;
/**
* Für Produktion hier die URL des deployten Workers eintragen,
* z. B. 'https://behoerdenklar-proxy.<account>.workers.dev'.
* Setup-Anleitung: proxy/README.md
*/
const PROXY_URL: string | null =
'https://behoerdenklar-proxy.behoerdenbrief.workers.dev';
/** true = Produktionsmodus über den Backend-Proxy (kein API-Key in der App nötig). */
export const NUTZT_PROXY = PROXY_URL !== null;
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
const ANTHROPIC_VERSION = '2023-06-01';
/** Standard-Modell für alle Aufrufe. */
export const MODELL = 'claude-opus-4-8';
/**
* Modell-Split zur Kostenoptimierung:
* - Brief-Analyse (Vision, Genauigkeit wichtig): Sonnet 5 (~2,5 Cent/Brief)
* - Übersetzung & Antwort-Entwürfe (einfache Textaufgaben): Haiku 4.5 (~halber Preis)
*
* Hinweis: Der Backend-Proxy erlaubt nur diese Modelle (Whitelist in
* proxy/src/index.ts) — bei Änderungen dort mitziehen.
*/
export const MODELL_ANALYSE = 'claude-sonnet-5';
export const MODELL_EINFACH = 'claude-haiku-4-5';
// ---- Typen für den Request-Body (Teilmenge der Messages API) ----
@@ -44,6 +60,8 @@ export interface DocumentBlock {
export type ContentBlock = TextBlock | ImageBlock | DocumentBlock;
export interface ClaudeRequest {
/** Welches Modell: MODELL_ANALYSE (Vision) oder MODELL_EINFACH (Text). */
modell: string;
system?: string;
messages: { role: 'user' | 'assistant'; content: string | ContentBlock[] }[];
/** JSON-Schema für Structured Output — garantiert valides JSON in der Antwort. */
@@ -75,10 +93,11 @@ export async function claudeJsonAufruf<T>(anfrage: ClaudeRequest): Promise<T> {
// 2. Request-Body bauen
const body: Record<string, unknown> = {
model: MODELL,
model: anfrage.modell,
max_tokens: anfrage.maxTokens ?? 16000,
// Adaptives Denken verbessert die Extraktions-Genauigkeit deutlich
thinking: { type: 'adaptive' },
// Kein explizites thinking-Feld: Sonnet 5 denkt adaptiv von selbst
// (gut für die Extraktions-Genauigkeit), Haiku 4.5 unterstützt den
// adaptiven Modus nicht und würde die Anfrage ablehnen.
messages: anfrage.messages,
};
if (anfrage.system) body.system = anfrage.system;
@@ -96,6 +115,8 @@ export async function claudeJsonAufruf<T>(anfrage: ClaudeRequest): Promise<T> {
};
if (PROXY_URL) {
url = PROXY_URL; // Der Proxy hält den API-Key serverseitig
// Anonyme ID, damit der Proxy das Tageslimit pro Gerät durchsetzen kann
headers['x-geraete-id'] = await holeGeraeteId();
} else {
const apiKey = await holeApiKey();
if (!apiKey) {
@@ -137,8 +158,11 @@ export async function claudeJsonAufruf<T>(anfrage: ClaudeRequest): Promise<T> {
technisch
);
case 429:
// Direkt-Modus: API-Rate-Limit. Proxy-Modus: Tageslimit pro Gerät.
throw new ClaudeFehler(
'Zu viele Anfragen. Bitte warten Sie einen Moment und versuchen Sie es erneut.',
PROXY_URL
? 'Das tägliche Kontingent ist aufgebraucht. Bitte versuchen Sie es morgen erneut.'
: 'Zu viele Anfragen. Bitte warten Sie einen Moment und versuchen Sie es erneut.',
technisch
);
case 413:

View File

@@ -14,6 +14,16 @@ import { BriefEintrag } from '../types';
const KEY_API = 'anthropic_api_key';
const KEY_BRIEFE = 'behoerdenklar_briefe';
const KEY_CONSENT = 'behoerdenklar_consent';
const KEY_GERAETE_ID = 'behoerdenklar_geraete_id';
const KEY_SCAN_ANZAHL = 'behoerdenklar_scan_anzahl';
/**
* Freemium: so viele Brief-Analysen sind kostenlos. Danach braucht es das
* Abo (kommt mit dem IAP-Einbau). Der Zähler liegt lokal — das ist bewusst
* "weich" (Neuinstallation setzt ihn zurück); die harte Grenze ist das
* Tageslimit des Backend-Proxys. Echte Abo-Prüfung folgt serverseitig.
*/
export const GRATIS_ANALYSEN = 3;
// ---- API-Key (sensibel -> SecureStore) ----
@@ -29,6 +39,42 @@ export async function speichereApiKey(key: string): Promise<void> {
}
}
// ---- Geräte-ID (anonym, nur fürs Tageslimit des Backend-Proxys) ----
/**
* Liefert eine zufällige, anonyme Geräte-ID (wird beim ersten Aufruf erzeugt).
* Sie enthält keine Gerätedaten und dient dem Proxy ausschließlich dazu,
* das Anfrage-Tageslimit pro Installation durchzusetzen.
*/
export async function holeGeraeteId(): Promise<string> {
let id = await AsyncStorage.getItem(KEY_GERAETE_ID);
if (!id) {
// Math.random reicht hier: die ID ist kein Geheimnis, nur ein Zähl-Schlüssel
id =
'g_' +
Array.from({ length: 24 }, () =>
Math.floor(Math.random() * 36).toString(36)
).join('');
await AsyncStorage.setItem(KEY_GERAETE_ID, id);
}
return id;
}
// ---- Freemium-Zähler ----
/** Wie viele Brief-Analysen wurden auf diesem Gerät schon durchgeführt? */
export async function holeAnzahlAnalysen(): Promise<number> {
const roh = await AsyncStorage.getItem(KEY_SCAN_ANZAHL);
const anzahl = roh ? parseInt(roh, 10) : 0;
return Number.isFinite(anzahl) ? anzahl : 0;
}
/** Nach jeder erfolgreichen Analyse aufrufen. */
export async function zaehleAnalyse(): Promise<void> {
const bisher = await holeAnzahlAnalysen();
await AsyncStorage.setItem(KEY_SCAN_ANZAHL, String(bisher + 1));
}
// ---- Einwilligung (Datenschutz-Consent) ----
export async function holeConsent(): Promise<boolean> {
@@ -59,6 +105,14 @@ export async function speichereBriefe(briefe: BriefEintrag[]): Promise<void> {
// ---- Alles löschen (Datenschutz-Anforderung) ----
export async function loescheAlleDaten(): Promise<void> {
await AsyncStorage.multiRemove([KEY_BRIEFE, KEY_CONSENT]);
// "Alle Daten löschen" heißt wirklich alle — auch Zähler und Geräte-ID.
// Dass damit das Gratis-Kontingent zurücksetzbar ist, nehmen wir in Kauf
// (Neuinstallation könnte das ohnehin); der Proxy limitiert weiterhin.
await AsyncStorage.multiRemove([
KEY_BRIEFE,
KEY_CONSENT,
KEY_GERAETE_ID,
KEY_SCAN_ANZAHL,
]);
await SecureStore.deleteItemAsync(KEY_API);
}

View File

@@ -4,7 +4,7 @@
* Archiv gecacht (siehe store), damit kein Aufruf doppelt läuft.
*/
import { BriefAnalyse, Sprache, Uebersetzung } from '../types';
import { claudeJsonAufruf } from './claudeClient';
import { claudeJsonAufruf, MODELL_EINFACH } from './claudeClient';
const UEBERSETZUNG_SCHEMA = {
type: 'object',
@@ -36,6 +36,8 @@ export async function uebersetzeAnalyse(
sprache: Sprache
): Promise<Uebersetzung> {
return claudeJsonAufruf<Uebersetzung>({
modell: MODELL_EINFACH,
maxTokens: 6000,
system: `Du übersetzt Erklärungen deutscher Behördenbriefe in andere Sprachen. Übersetze in einfache, klare Sprache (Niveau A2/B1 der Zielsprache). Deutsche Fachbegriffe (Feld "begriff") NICHT übersetzen — sie stehen so im Original-Brief und der Nutzer muss sie wiedererkennen. Nur die Erklärungen übersetzen.`,
schema: UEBERSETZUNG_SCHEMA,
messages: [

View File

@@ -0,0 +1,161 @@
/**
* Tests für die Dringlichkeits-Ampel — die kritischste Logik der App:
* Eine falsche Ampel könnte dazu führen, dass ein Nutzer eine Behörden-Frist
* verpasst. Deshalb hier alle Grenzfälle deterministisch abgesichert.
*/
import { berechneAmpel, tageBis } from '../ampel';
import { BriefAnalyse } from '../../types';
/** Fixes "Heute" für alle Tests: 15.07.2026 (Mitte des Monats, kein Randfall). */
const HEUTE = new Date('2026-07-15T10:30:00');
/** Minimal gültige Analyse; frist/termin pro Test überschreiben. */
function analyse(teile: Partial<BriefAnalyse>): BriefAnalyse {
return {
brieftyp: 'Test',
absender: 'Testamt',
kernaussage: '',
erklaerung_einfach: '',
fachbegriffe: [],
frist: null,
termin: null,
checkliste: [],
antwort_noetig: false,
antwort_optionen: [],
...teile,
};
}
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(HEUTE);
});
afterAll(() => {
jest.useRealTimers();
});
describe('tageBis', () => {
it('liefert 0 für heute', () => {
expect(tageBis('2026-07-15')).toBe(0);
});
it('liefert positive Tage für die Zukunft', () => {
expect(tageBis('2026-07-16')).toBe(1);
expect(tageBis('2026-07-22')).toBe(7);
});
it('liefert negative Tage für die Vergangenheit', () => {
expect(tageBis('2026-07-14')).toBe(-1);
expect(tageBis('2026-07-01')).toBe(-14);
});
it('zählt ganze Tage unabhängig von der Uhrzeit "jetzt"', () => {
// 10:30 Uhr darf keine halben Tage erzeugen
expect(tageBis('2026-07-18')).toBe(3);
});
it('funktioniert über Monatsgrenzen hinweg', () => {
expect(tageBis('2026-08-01')).toBe(17);
});
});
describe('berechneAmpel — Stufen', () => {
it('grün ohne Frist und Termin', () => {
const status = berechneAmpel(analyse({}));
expect(status.stufe).toBe('gruen');
expect(status.text).toContain('Keine Frist');
});
it('rot bei abgelaufener Frist (inkl. Hinweis)', () => {
const status = berechneAmpel(
analyse({ frist: { datum: '2026-07-10', aktion: 'Unterlagen einreichen' } })
);
expect(status.stufe).toBe('rot');
expect(status.text).toContain('abgelaufen');
});
it('rot bei Frist heute', () => {
const status = berechneAmpel(
analyse({ frist: { datum: '2026-07-15', aktion: 'Antwort' } })
);
expect(status.stufe).toBe('rot');
expect(status.text).toContain('HEUTE');
});
it('rot bei Frist in 6 Tagen (< 7)', () => {
const status = berechneAmpel(
analyse({ frist: { datum: '2026-07-21', aktion: 'Antwort' } })
);
expect(status.stufe).toBe('rot');
});
it('gelb bei Frist in genau 7 Tagen (Grenzwert)', () => {
const status = berechneAmpel(
analyse({ frist: { datum: '2026-07-22', aktion: 'Antwort' } })
);
expect(status.stufe).toBe('gelb');
});
it('gelb bei Frist in genau 21 Tagen (Grenzwert)', () => {
const status = berechneAmpel(
analyse({ frist: { datum: '2026-08-05', aktion: 'Antwort' } })
);
expect(status.stufe).toBe('gelb');
});
it('grün bei Frist in 22 Tagen (> 21)', () => {
const status = berechneAmpel(
analyse({ frist: { datum: '2026-08-06', aktion: 'Antwort' } })
);
expect(status.stufe).toBe('gruen');
});
});
describe('berechneAmpel — Frist und Termin kombiniert', () => {
it('das dringendste Datum bestimmt die Ampel (Termin näher als Frist)', () => {
const status = berechneAmpel(
analyse({
frist: { datum: '2026-08-20', aktion: 'Unterlagen' }, // 36 Tage → grün
termin: { datum: '2026-07-18', uhrzeit: '09:30', ort: 'Raum 1' }, // 3 Tage → rot
})
);
expect(status.stufe).toBe('rot');
expect(status.text).toContain('Termin');
});
it('das dringendste Datum bestimmt die Ampel (Frist näher als Termin)', () => {
const status = berechneAmpel(
analyse({
frist: { datum: '2026-07-25', aktion: 'Antwort' }, // 10 Tage → gelb
termin: { datum: '2026-09-01', uhrzeit: null, ort: null }, // weit weg
})
);
expect(status.stufe).toBe('gelb');
expect(status.text).toContain('Frist');
});
it('nur Termin ohne Frist funktioniert', () => {
const status = berechneAmpel(
analyse({ termin: { datum: '2026-07-16', uhrzeit: null, ort: null } })
);
expect(status.stufe).toBe('rot');
expect(status.text).toContain('Termin');
});
});
describe('berechneAmpel — Textdetails', () => {
it('Einzahl bei 1 Tag ("in 1 Tag", nicht "Tagen")', () => {
const status = berechneAmpel(
analyse({ frist: { datum: '2026-07-16', aktion: 'Antwort' } })
);
expect(status.text).toContain('in 1 Tag ');
});
it('Einzahl bei 1 Tag abgelaufen', () => {
const status = berechneAmpel(
analyse({ frist: { datum: '2026-07-14', aktion: 'Antwort' } })
);
expect(status.text).toContain('vor 1 Tag)');
});
});

122
store/app-store-texte.md Normal file
View File

@@ -0,0 +1,122 @@
# App-Store-Texte — BehördenKlar (iOS)
Vorbereitet zum Einfügen in App Store Connect, sobald das Apple-Developer-Konto
da ist. Zeichenlimits sind von Apple vorgegeben und hier eingehalten.
---
## App-Name (max. 30 Zeichen)
```
BehördenKlar Brief-Hilfe
```
*(26 Zeichen ✓ — Alternative: „BehördenKlar" pur; der Untertitel trägt dann den Slogan)*
## Untertitel (max. 30 Zeichen)
```
Amtsdeutsch einfach erklärt
```
## Kategorie
- Primär: **Dienstprogramme** (Utilities)
- Sekundär: **Produktivität**
## Keywords (max. 100 Zeichen, kommagetrennt, ohne Leerzeichen)
```
behörde,amt,brief,bescheid,jobcenter,widerspruch,frist,amtsdeutsch,einfache sprache,übersetzen
```
## Werbetext / Promo-Text (max. 170 Zeichen, jederzeit änderbar)
```
Brief vom Amt? Einfach fotografieren BehördenKlar erklärt ihn in einfacher Sprache, findet Fristen und hilft beim Antworten. In 10 Sprachen.
```
## Beschreibung (max. 4000 Zeichen)
```
Ein Brief vom Amt und nichts verstanden? Damit sind Sie nicht allein. BehördenKlar übersetzt Amtsdeutsch in einfache, klare Sprache.
SO FUNKTIONIERT ES
Fotografieren Sie den Brief. Nach weniger als einer Minute wissen Sie:
• Was will das Amt von mir? in 2-3 einfachen Sätzen
• Was muss ich jetzt tun? als klare Checkliste
• Bis wann? alle Fristen und Termine auf einen Blick
DIE AMPEL ZEIGT, WIE DRINGEND ES IST
🔴 Rot: Frist in weniger als 7 Tagen jetzt handeln
🟡 Gelb: bald erledigen
🟢 Grün: keine Frist, nur zur Information
VERSTEHEN IN IHRER SPRACHE
Alle Erklärungen lassen sich in 9 weitere Sprachen übersetzen: Türkisch, Arabisch, Englisch, Russisch, Ukrainisch, Französisch, Farsi, Rumänisch und Polnisch. Die deutschen Fachbegriffe bleiben stehen, damit Sie sie im Brief wiederfinden.
ANTWORTEN LEICHT GEMACHT
BehördenKlar erstellt Ihnen einen fertigen Antwort-Entwurf zum Beispiel um einen Termin zu bestätigen oder zu verschieben, Unterlagen nachzureichen oder Widerspruch einzulegen. Sie können den Text frei bearbeiten und als PDF teilen oder drucken.
NICHTS MEHR VERPASSEN
• Termine mit einem Tipp in Ihren Kalender übernehmen
• Automatische Erinnerungen vor jeder Frist
• Alle gescannten Briefe übersichtlich im Archiv
IHRE DATEN GEHÖREN IHNEN
• Alle Ergebnisse werden nur auf Ihrem Gerät gespeichert
• Kein Konto, keine Registrierung nötig
• Mit einem Tipp alles löschen
AUSSERDEM
• Behörden-Glossar: die wichtigsten Amtsbegriffe einfach erklärt auch offline
• Vorlese-Funktion für alle Erklärungen
• Große Schrift und große Tasten einfach zu bedienen
3 Brief-Analysen sind kostenlos. Danach analysieren Sie mit dem Abo unbegrenzt weiter.
Wichtig: BehördenKlar erklärt Briefe verständlich, ersetzt aber keine Rechtsberatung. Bei rechtlich wichtigen Entscheidungen wenden Sie sich bitte an eine Beratungsstelle oder eine Anwältin/einen Anwalt.
```
## Screenshot-Plan (6,7"-iPhone Pflicht, je 1290×2796 px)
1. **Hero:** Scan-Screen mit Slogan „Brief fotografieren sofort verstehen"
2. **Analyse:** Ergebnis mit Kernaussage + roter Ampel „Frist in 5 Tagen"
3. **Checkliste:** „Das müssen Sie tun" mit Häkchen-Liste
4. **Übersetzung:** dieselbe Analyse auf Türkisch oder Arabisch
5. **Antwort:** fertiger Antwort-Entwurf mit „Als PDF teilen"-Button
6. **Archiv:** Briefliste mit Ampeln
*Tipp: Screenshots im Simulator (iPhone 17 Pro Max) mit dem Testbrief erstellen —
Cmd+S speichert einen Screenshot in der passenden Auflösung.*
## Altersfreigabe
4+ (keine bedenklichen Inhalte)
## App-Review-Hinweise (Feld „Notes" bei der Einreichung)
```
Die App analysiert Fotos von deutschen Behördenbriefen mit einem KI-Dienst
(Anthropic Claude) über unseren eigenen Backend-Server. Zum Testen:
Ein Beispiel-Behördenbrief zum Abfotografieren liegt unter [URL ZU TESTBRIEF
EINFÜGEN] bereit, oder nutzen Sie einen beliebigen deutschen Behördenbrief.
Vor dem ersten Scan erscheint eine Datenschutz-Einwilligung. 3 Analysen sind
ohne Kauf möglich.
```
## URLs (live, für die Formularfelder in App Store Connect)
- **Support-URL:** https://behoerdenklar.pages.dev
- **Datenschutz-URL:** https://behoerdenklar.pages.dev/datenschutz
- Kontakt-E-Mail: behoerdenbriefhelfer@gmail.com
- Quelldateien: `webseite/` — neu deployen mit
`npx wrangler pages deploy ../webseite --project-name behoerdenklar` (aus proxy/)
## Noch offen (vor Einreichung)
- [ ] „App-Datenschutz"-Fragebogen in App Store Connect ausfüllen
(Datenerfassung: Fotos → werden verarbeitet, nicht mit Identität verknüpft,
kein Tracking)
- [ ] Screenshots erstellen (siehe Plan oben)
```

View File

@@ -1,6 +1,8 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
"strict": true,
"types": ["jest"]
},
"exclude": ["proxy", "node_modules"]
}

149
webseite/datenschutz.html Normal file
View File

@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Datenschutzerklärung BehördenKlar</title>
<meta name="robots" content="noindex">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #1a1a2e; background: #f4f8fb; line-height: 1.7;
}
.wrap { max-width: 640px; margin: 0 auto; padding: 32px 20px 64px; }
h1 { font-size: 28px; color: #144d8c; margin: 24px 0 4px; }
.stand { color: #4a5568; margin-bottom: 16px; }
h2 { font-size: 20px; color: #144d8c; margin: 24px 0 8px; }
h3 { font-size: 17px; color: #1a1a2e; margin: 16px 0 6px; }
p, li { margin-bottom: 8px; }
ul { padding-left: 24px; }
.karte {
background: #fff; border-radius: 16px; padding: 24px;
box-shadow: 0 2px 8px rgba(20, 77, 140, 0.08);
}
a { color: #1a6bc4; }
.zurueck { display: inline-block; margin-top: 24px; }
</style>
</head>
<body>
<div class="wrap">
<h1>Datenschutzerklärung</h1>
<p class="stand">für die App „BehördenKlar" — Stand: 14.07.2026</p>
<div class="karte">
<h2>1. Verantwortlicher</h2>
<p>
Justin Klein<br>
Am Schwimmbad 10<br>
67722 Winnweiler<br>
E-Mail: <a href="mailto:behoerdenbriefhelfer@gmail.com">behoerdenbriefhelfer@gmail.com</a>
</p>
<h2>2. Was macht die App?</h2>
<p>
BehördenKlar hilft Ihnen, Behördenbriefe zu verstehen: Sie fotografieren
einen Brief, und eine Künstliche Intelligenz (KI) erklärt den Inhalt in
einfacher Sprache, findet Fristen und Termine und hilft beim Antworten.
</p>
<h2>3. Welche Daten werden verarbeitet?</h2>
<h3>a) Brieffotos und PDF-Dateien (nur mit Ihrer Einwilligung)</h3>
<p>Wenn Sie einen Brief scannen, wird das Foto bzw. die PDF-Datei zur Analyse übertragen an:</p>
<ul>
<li>
<strong>Unseren Server</strong> (Cloudflare Workers, Anbieter:
Cloudflare, Inc., USA) — er leitet die Anfrage nur weiter und
speichert den Briefinhalt nicht.
</li>
<li>
<strong>Anthropic PBC (USA)</strong> — den Betreiber des KI-Modells
„Claude". Anthropic verarbeitet die Daten ausschließlich zur
Erstellung der Analyse, speichert sie maximal 30 Tage (zur
Missbrauchserkennung) und verwendet sie nicht zum Training der KI.
</li>
</ul>
<p>
<strong>Wichtig:</strong> Behördenbriefe können sehr persönliche und
besondere Kategorien von Daten enthalten (z. B. Angaben zu
Sozialleistungen, Gesundheit oder finanzieller Situation). Die Analyse
erfolgt deshalb nur nach Ihrer ausdrücklichen Einwilligung, die Sie vor
dem ersten Scan erteilen (Art. 6 Abs. 1 lit. a, Art. 9 Abs. 2 lit. a
DSGVO). Sie können die Einwilligung jederzeit widerrufen, indem Sie in
den Einstellungen „Alle Daten löschen" wählen.
</p>
<h3>b) Analyse-Ergebnisse, Übersetzungen, Antwort-Entwürfe</h3>
<p>
Alle Ergebnisse werden ausschließlich lokal auf Ihrem Gerät gespeichert.
Wir betreiben keine Nutzerkonten und keine Cloud-Speicherung Ihrer Briefe.
</p>
<h3>c) Anonyme Geräte-Kennung</h3>
<p>
Die App erzeugt eine zufällige, anonyme Kennung. Sie enthält keine
Angaben über Sie oder Ihr Gerät und dient ausschließlich dazu, ein
tägliches Nutzungslimit pro Installation durchzusetzen (Schutz vor
Missbrauch, Art. 6 Abs. 1 lit. f DSGVO). Sie wird zusammen mit einem
Tageszähler für maximal 48 Stunden auf unserem Server gespeichert.
</p>
<h3>d) Kalender, Erinnerungen, Vorlesen</h3>
<p>
Kalendereinträge und Frist-Erinnerungen werden nur auf Ihrem Gerät
angelegt (mit Ihrer Erlaubnis über die iOS-/Android-Berechtigungen).
Die Vorlese-Funktion nutzt die Sprachausgabe Ihres Geräts. Dabei werden
keine Daten an uns übertragen.
</p>
<h2>4. Übermittlung in die USA</h2>
<p>
Anthropic PBC und Cloudflare, Inc. haben ihren Sitz in den USA. Die
Übermittlung stützt sich auf den Angemessenheitsbeschluss der
EU-Kommission zum EU-U.S. Data Privacy Framework bzw. auf
EU-Standardvertragsklauseln (Art. 46 DSGVO).
</p>
<h2>5. Speicherdauer</h2>
<ul>
<li>Brieffotos bei Anthropic: maximal 30 Tage, danach automatische Löschung</li>
<li>Anonyme Geräte-Kennung + Tageszähler auf unserem Server: maximal 48 Stunden</li>
<li>Daten auf Ihrem Gerät: bis Sie sie löschen („Alle Daten löschen" in den Einstellungen oder Deinstallation der App)</li>
</ul>
<h2>6. Ihre Rechte</h2>
<p>
Sie haben nach der DSGVO das Recht auf Auskunft (Art. 15), Berichtigung
(Art. 16), Löschung (Art. 17), Einschränkung der Verarbeitung (Art. 18),
Datenübertragbarkeit (Art. 20) und Widerspruch (Art. 21). Außerdem
können Sie sich bei einer Datenschutz-Aufsichtsbehörde beschweren
(Art. 77 DSGVO), z. B. beim Landesbeauftragten für den Datenschutz und
die Informationsfreiheit Rheinland-Pfalz.
</p>
<p>
Da wir keine Nutzerkonten führen, liegen die meisten Daten nur auf
Ihrem Gerät — dort können Sie sie selbst einsehen und löschen. Für
Anfragen zu den serverseitig verarbeiteten Daten wenden Sie sich an:
<a href="mailto:behoerdenbriefhelfer@gmail.com">behoerdenbriefhelfer@gmail.com</a>
</p>
<h2>7. Keine Rechtsberatung</h2>
<p>
BehördenKlar erklärt Briefe verständlich, ersetzt aber keine
Rechtsberatung. Bei rechtlich wichtigen Entscheidungen wenden Sie sich
an eine Beratungsstelle oder eine Anwältin/einen Anwalt.
</p>
<h2>8. Änderungen</h2>
<p>
Wir passen diese Datenschutzerklärung an, wenn sich die App oder die
Rechtslage ändert. Die aktuelle Fassung finden Sie immer auf dieser
Seite.
</p>
</div>
<a class="zurueck" href="index.html">← Zurück zur Startseite</a>
</div>
</body>
</html>

46
webseite/impressum.html Normal file
View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Impressum BehördenKlar</title>
<meta name="robots" content="noindex">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #1a1a2e; background: #f4f8fb; line-height: 1.7;
}
.wrap { max-width: 640px; margin: 0 auto; padding: 32px 20px 64px; }
h1 { font-size: 28px; color: #144d8c; margin: 24px 0 16px; }
.karte {
background: #fff; border-radius: 16px; padding: 24px;
box-shadow: 0 2px 8px rgba(20, 77, 140, 0.08);
}
a { color: #1a6bc4; }
.zurueck { display: inline-block; margin-top: 24px; }
</style>
</head>
<body>
<div class="wrap">
<h1>Impressum</h1>
<div class="karte">
<p><strong>Angaben gemäß § 5 DDG:</strong></p>
<p style="margin-top: 12px;">
Justin Klein<br>
Am Schwimmbad 10<br>
67722 Winnweiler<br>
Deutschland
</p>
<p style="margin-top: 12px;">
E-Mail: <a href="mailto:behoerdenbriefhelfer@gmail.com">behoerdenbriefhelfer@gmail.com</a>
</p>
<p style="margin-top: 16px;">
<strong>Verantwortlich für den Inhalt:</strong><br>
Justin Klein (Anschrift wie oben)
</p>
</div>
<a class="zurueck" href="index.html">← Zurück zur Startseite</a>
</div>
</body>
</html>

75
webseite/index.html Normal file
View File

@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BehördenKlar Behördenbriefe einfach verstehen</title>
<meta name="description" content="BehördenKlar erklärt Behördenbriefe in einfacher Sprache. Support und Kontakt.">
<style>
:root { color-scheme: light; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #1a1a2e; background: #f4f8fb; line-height: 1.7;
}
.wrap { max-width: 640px; margin: 0 auto; padding: 32px 20px 64px; }
header { text-align: center; padding: 48px 0 32px; }
.logo { font-size: 56px; }
h1 { font-size: 32px; margin-top: 12px; color: #144d8c; }
.slogan { font-size: 18px; color: #4a5568; margin-top: 8px; }
.karte {
background: #fff; border-radius: 16px; padding: 24px;
margin-top: 24px; box-shadow: 0 2px 8px rgba(20, 77, 140, 0.08);
}
h2 { font-size: 20px; color: #144d8c; margin-bottom: 12px; }
a { color: #1a6bc4; }
.kontakt { font-size: 18px; font-weight: 600; }
footer { text-align: center; margin-top: 40px; font-size: 15px; color: #4a5568; }
footer a { margin: 0 10px; }
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo">📬</div>
<h1>BehördenKlar</h1>
<p class="slogan">Behördenbriefe fotografieren und einfach verstehen.</p>
</header>
<div class="karte">
<h2>Was ist BehördenKlar?</h2>
<p>
BehördenKlar ist eine App für iPhone: Sie fotografieren einen Brief vom
Amt, und die App erklärt ihn in einfacher Sprache — mit allen Fristen,
Terminen und einer Checkliste, was zu tun ist. Auf Wunsch übersetzt in
9 weitere Sprachen.
</p>
</div>
<div class="karte">
<h2>Support &amp; Kontakt</h2>
<p>
Sie haben eine Frage, ein Problem mit der App oder einen Verbesserungsvorschlag?
Schreiben Sie uns — wir antworten so schnell wie möglich:
</p>
<p class="kontakt" style="margin-top: 12px;">
📧 <a href="mailto:behoerdenbriefhelfer@gmail.com">behoerdenbriefhelfer@gmail.com</a>
</p>
</div>
<div class="karte">
<h2>Wichtiger Hinweis</h2>
<p>
BehördenKlar erklärt Briefe verständlich, ersetzt aber keine
Rechtsberatung. Bei rechtlich wichtigen Entscheidungen wenden Sie sich
bitte an eine Beratungsstelle oder eine Anwältin/einen Anwalt.
</p>
</div>
<footer>
<a href="impressum.html">Impressum</a> ·
<a href="datenschutz.html">Datenschutzerklärung</a>
</footer>
</div>
</body>
</html>