1 version

This commit is contained in:
2026-07-12 19:59:15 +02:00
parent f1165b5404
commit 88c49e1b54
39 changed files with 9328 additions and 52 deletions

5
.claude/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}

43
.gitignore vendored
View File

@@ -1,8 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
out/
build/
.env
.env.local
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.log
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

3
AGENTS.md Normal file
View File

@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.

53
App.tsx Normal file
View File

@@ -0,0 +1,53 @@
/**
* BehördenKlar — Behördenbriefe verstehen, übersetzen, beantworten.
* Einstiegspunkt: Navigation + Initialisierung (Archiv, Benachrichtigungen).
*/
import React, { useEffect } from 'react';
import { StatusBar } from 'expo-status-bar';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { RootStackParamList } from './src/types';
import { useAppStore } from './src/store/useAppStore';
import { initialisiereBenachrichtigungen } from './src/services/erinnerungen';
import { farben, schrift } from './src/theme';
import { HomeScreen } from './src/screens/HomeScreen';
import { ConsentScreen } from './src/screens/ConsentScreen';
import { ScanScreen } from './src/screens/ScanScreen';
import { AnalyseScreen } from './src/screens/AnalyseScreen';
import { AntwortScreen } from './src/screens/AntwortScreen';
import { GlossarScreen } from './src/screens/GlossarScreen';
import { EinstellungenScreen } from './src/screens/EinstellungenScreen';
const Stack = createNativeStackNavigator<RootStackParamList>();
export default function App() {
const initialisiere = useAppStore((s) => s.initialisiere);
useEffect(() => {
initialisiereBenachrichtigungen();
initialisiere();
}, [initialisiere]);
return (
<NavigationContainer>
<StatusBar style="dark" />
<Stack.Navigator
screenOptions={{
headerStyle: { backgroundColor: farben.hintergrund },
headerTintColor: farben.primaer,
headerTitleStyle: { fontSize: schrift.gross, fontWeight: '700' },
headerBackTitle: 'Zurück',
contentStyle: { backgroundColor: farben.hintergrund },
}}
>
<Stack.Screen name="Home" component={HomeScreen} options={{ title: 'BehördenKlar' }} />
<Stack.Screen name="Consent" component={ConsentScreen} options={{ title: 'Datenschutz' }} />
<Stack.Screen name="Scan" component={ScanScreen} options={{ title: 'Brief scannen' }} />
<Stack.Screen name="Analyse" component={AnalyseScreen} options={{ title: 'Ihr Brief erklärt' }} />
<Stack.Screen name="Antwort" component={AntwortScreen} options={{ title: 'Antwort erstellen' }} />
<Stack.Screen name="Glossar" component={GlossarScreen} options={{ title: 'Behörden-Glossar' }} />
<Stack.Screen name="Einstellungen" component={EinstellungenScreen} options={{ title: 'Einstellungen' }} />
</Stack.Navigator>
</NavigationContainer>
);
}

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

112
README.md
View File

@@ -1,60 +1,78 @@
# webklar-preview-template
# BehördenKlar
Vorlage fuer Kunden-Websites mit automatischer Preview unter `*.project.webklar.com`.
Behördenbriefe scannen, in einfacher Sprache verstehen, übersetzen und direkt beantworten.
## Preview aktivieren
## Funktionen
Dieses Repo enthaelt [`.webklar-preview.json`](.webklar-preview.json). Beim Push auf den konfigurierten Branch startet der Deploy:
- 📷 **Brief scannen** — Foto, Galerie-Bild oder PDF
- 🧠 **KI-Analyse** (Claude Vision) — ein Aufruf liefert strukturiert:
- „Was will das Amt von mir?" in 2-3 Sätzen (A2-Niveau)
- Ausführliche Erklärung in einfacher Sprache
- Fachbegriffe mit Erklärung
- Fristen & Termine (exakte Daten)
- Checkliste („Diese Unterlagen mitbringen…")
- 🚦 **Dringlichkeits-Ampel** — lokal berechnet: 🔴 < 7 Tage · 🟡 721 Tage · 🟢 keine Frist
- 🌐 **Übersetzung** in 9 Sprachen (Türkisch, Arabisch, Englisch, Russisch, Ukrainisch, Französisch, Farsi, Rumänisch, Polnisch) — on demand, gecacht
- ✍️ **Antwort-Generator** — Vorlagen (Terminbestätigung, Verschiebung, Widerspruch, Unterlagen nachreichen, Rückfrage), frei editierbar, PDF-Export
- 🗓️ **Kalender-Export** für Termine, ⏰ **lokale Erinnerungen** 3 + 1 Tage vor Fristen
- 📖 **Behörden-Glossar** (offline), 🔊 **Vorlese-Funktion**, 📡 **Offline-Hinweis**
- 🔒 **Datenschutz**: Einwilligung vor erstem Scan, alle Daten nur lokal, „Alles löschen"-Funktion
- URL: `https://project.webklar.com/webhook/gitea`
- Vorschau: `https://<subdomain>.project.webklar.com`
## Starten (Entwicklung)
## Konfiguration (`.webklar-preview.json`)
| Feld | Beschreibung |
|------|----------------|
| `enabled` | `true` = Preview-Deploy aktiv |
| `type` | `static` (Dateien direkt) oder `node_build` (npm build) |
| `branch` | Branch fuer Deploy, z. B. `main` |
| `displayName` | Anzeigename im Kundenportal |
| `subdomain` | Optional; Default = Repo-Name |
**Keine Passwoerter** in dieser Datei.
### Beispiel fuer statische Seite
```json
{
"enabled": true,
"type": "static",
"branch": "main",
"displayName": "Friseur Mueller",
"subdomain": "friseur-mueller"
}
```bash
npm install
npx expo start
```
### Beispiel fuer Vite/React (Build)
App in Expo Go (oder Development Build) öffnen, dann:
```json
{
"enabled": true,
"type": "node_build",
"branch": "main",
"displayName": "Restaurant Demo",
"subdomain": "restaurant-demo"
}
1. **Einstellungen** → Anthropic-API-Schlüssel eintragen (von console.anthropic.com).
Der Schlüssel landet verschlüsselt im Secure Store des Geräts.
2. **Brief scannen** → Einwilligung bestätigen → Foto machen.
> Hinweis: Erinnerungen (Benachrichtigungen) sind in Expo Go auf Android
> eingeschränkt — im Development Build (`npx expo run:android`) voll verfügbar.
## 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`:
```ts
const PROXY_URL: string | null = null; // -> eigene Server-URL eintragen
```
## Neues Projekt aus dieser Vorlage
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.
1. In Gitea: **Use this template** / Repository aus Template erstellen
2. `displayName` und `subdomain` in `.webklar-preview.json` anpassen
3. Website-Inhalt ersetzen (`index.html` oder eigenes Frontend)
4. Push auf `main` <20> Webhook deployt automatisch
5. Im Admin-Portal (`project.webklar.com`) Kunde dem Projekt zuweisen
Abrechnung der Endkunden dann per Abo/In-App-Kauf.
## Gitea-Webhook (einmalig pro Org/Repo)
## Architektur
- **URL:** `https://project.webklar.com/webhook/gitea`
- **Secret:** `GITEA_WEBHOOK_TOKEN` (Server-`.env`)
- **Events:** Push
```
src/
services/
claudeClient.ts Low-Level Claude-API-Client (Dev-Direkt / Prod-Proxy)
analyse.ts Vision-Aufruf + JSON-Schema (Structured Output)
uebersetzung.ts Übersetzung pro Sprache, gecacht
antwort.ts Antwort-Entwurf-Generator
storage.ts SecureStore (Key) + AsyncStorage (Archiv, Consent)
erinnerungen.ts lokale Benachrichtigungen vor Fristen
kalender.ts Termin-Export
utils/ampel.ts Dringlichkeits-Ampel (deterministisch, lokal)
store/useAppStore.ts Zustand-Store, synchron zu AsyncStorage
screens/ Home, Consent, Scan, Analyse, Antwort, Glossar, Einstellungen
components/ GrossButton, Ampel (barrierefrei: 56px-Targets, große Schrift)
data/glossar.ts Offline-Glossar
```
Die KI-Antworten sind über **Structured Output** (JSON-Schema) garantiert
valide — kein fragiles Text-Parsing.
## Wichtig
Diese App ersetzt keine Rechtsberatung. Bei rechtlich relevanten Antworten
(z. B. Widerspruch) Beratungsstelle oder Anwalt hinzuziehen.

44
app.json Normal file
View File

@@ -0,0 +1,44 @@
{
"expo": {
"name": "BehoerdenKlar",
"slug": "BehoerdenKlar",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-secure-store",
"expo-sharing",
[
"expo-image-picker",
{
"cameraPermission": "BehördenKlar braucht die Kamera, um Ihre Behördenbriefe zu fotografieren.",
"photosPermission": "BehördenKlar braucht Zugriff auf Ihre Fotos, um gespeicherte Brief-Fotos auszuwählen."
}
],
[
"expo-calendar",
{
"calendarPermission": "BehördenKlar trägt auf Wunsch Behörden-Termine in Ihren Kalender ein."
}
],
"expo-notifications",
"expo-status-bar"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

BIN
assets/splash-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

8
index.ts Normal file
View File

@@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

6600
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "behoerdenklar",
"version": "1.0.0",
"main": "index.ts",
"dependencies": {
"@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",
"react": "19.2.3",
"react-native": "0.85.3",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/react": "~19.2.2",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"private": true
}

36
src/components/Ampel.tsx Normal file
View File

@@ -0,0 +1,36 @@
/**
* Dringlichkeits-Ampel als gut sichtbares Banner.
* Farbe + Symbol + Text — nicht nur Farbe (Barrierefreiheit: Farbenblindheit).
*/
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { AmpelStatus } from '../utils/ampel';
import { abstand, schrift } from '../theme';
const SYMBOL: Record<string, string> = { rot: '⚠️', gelb: '⏰', gruen: '' };
export function Ampel({ status }: { status: AmpelStatus }) {
return (
<View
style={[styles.banner, { backgroundColor: status.hintergrund, borderColor: status.farbe }]}
accessibilityRole="alert"
accessibilityLabel={`Dringlichkeit: ${status.text}`}
>
<Text style={styles.symbol}>{SYMBOL[status.stufe]}</Text>
<Text style={[styles.text, { color: status.farbe }]}>{status.text}</Text>
</View>
);
}
const styles = StyleSheet.create({
banner: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 2,
borderRadius: 12,
padding: abstand.m,
gap: abstand.s,
},
symbol: { fontSize: 28 },
text: { flex: 1, fontSize: schrift.gross, fontWeight: '700' },
});

View File

@@ -0,0 +1,76 @@
/**
* Großer, barrierefreier Button: min. 56px hoch, große Schrift,
* hoher Kontrast. Varianten: primär (gefüllt) und sekundär (Umriss).
*/
import React from 'react';
import { ActivityIndicator, Pressable, StyleSheet, Text } from 'react-native';
import { farben, schrift, abstand, TOUCH_TARGET } from '../theme';
interface Props {
titel: string;
onPress: () => void;
variante?: 'primaer' | 'sekundaer' | 'gefahr';
deaktiviert?: boolean;
laedt?: boolean;
symbol?: string;
}
export function GrossButton({
titel,
onPress,
variante = 'primaer',
deaktiviert = false,
laedt = false,
symbol,
}: Props) {
const istPrimaer = variante === 'primaer';
const istGefahr = variante === 'gefahr';
return (
<Pressable
onPress={onPress}
disabled={deaktiviert || laedt}
accessibilityRole="button"
accessibilityLabel={titel}
style={({ pressed }) => [
styles.button,
istPrimaer && styles.primaer,
variante === 'sekundaer' && styles.sekundaer,
istGefahr && styles.gefahr,
(deaktiviert || laedt) && styles.deaktiviert,
pressed && styles.gedrueckt,
]}
>
{laedt ? (
<ActivityIndicator color={istPrimaer || istGefahr ? farben.primaerText : farben.primaer} />
) : (
<Text
style={[
styles.text,
(istPrimaer || istGefahr) && { color: farben.primaerText },
variante === 'sekundaer' && { color: farben.primaer },
]}
>
{symbol ? `${symbol} ` : ''}
{titel}
</Text>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
minHeight: TOUCH_TARGET,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: abstand.l,
paddingVertical: abstand.s,
},
primaer: { backgroundColor: farben.primaer },
sekundaer: { backgroundColor: farben.hintergrund, borderWidth: 2, borderColor: farben.primaer },
gefahr: { backgroundColor: farben.fehler },
deaktiviert: { opacity: 0.5 },
gedrueckt: { opacity: 0.8 },
text: { fontSize: schrift.gross, fontWeight: '700', textAlign: 'center' },
});

131
src/data/glossar.ts Normal file
View File

@@ -0,0 +1,131 @@
/**
* Behörden-Glossar: häufige Amtsbegriffe mit Erklärung in einfacher Sprache.
* Statisch und lokal — funktioniert offline, kostet keine API-Aufrufe.
*/
export interface GlossarEintrag {
begriff: string;
erklaerung: string;
}
export const GLOSSAR: GlossarEintrag[] = [
{
begriff: 'Widerspruch',
erklaerung:
'Wenn Sie mit einer Entscheidung des Amts nicht einverstanden sind, können Sie schriftlich "Nein" sagen. Das nennt man Widerspruch. Dafür gibt es meist eine Frist von einem Monat.',
},
{
begriff: 'Mitwirkungspflicht',
erklaerung:
'Sie müssen dem Amt helfen, Ihren Fall zu bearbeiten. Zum Beispiel: Unterlagen abgeben, Fragen beantworten, zu Terminen kommen. Wenn Sie das nicht tun, kann das Amt Geld kürzen.',
},
{
begriff: 'Bescheid',
erklaerung:
'Ein offizieller Brief mit einer Entscheidung des Amts. Zum Beispiel: wie viel Geld Sie bekommen, oder ob ein Antrag angenommen wurde.',
},
{
begriff: 'Frist',
erklaerung:
'Ein Datum, bis zu dem Sie etwas tun müssen. Nach der Frist ist es oft zu spät. Fristen sind sehr wichtig — verpassen Sie sie nicht!',
},
{
begriff: 'Anhörung',
erklaerung:
'Bevor das Amt etwas Negatives entscheidet, dürfen Sie Ihre Sicht erklären. Das Amt schickt dazu einen Brief mit Fragen. Antworten Sie unbedingt!',
},
{
begriff: 'Bewilligung',
erklaerung: 'Das Amt sagt "Ja" zu Ihrem Antrag. Sie bekommen, was Sie beantragt haben.',
},
{
begriff: 'Ablehnung / Ablehnungsbescheid',
erklaerung:
'Das Amt sagt "Nein" zu Ihrem Antrag. Wenn Sie das falsch finden, können Sie oft Widerspruch einlegen.',
},
{
begriff: 'Aktenzeichen / Geschäftszeichen',
erklaerung:
'Eine Nummer für Ihren Fall. Schreiben Sie diese Nummer immer in Ihre Briefe an das Amt — dann findet das Amt Ihren Fall schneller.',
},
{
begriff: 'Sanktion / Minderung',
erklaerung:
'Eine Strafe vom Jobcenter: Sie bekommen weniger Geld, zum Beispiel weil Sie einen Termin verpasst haben.',
},
{
begriff: 'Eingliederungsvereinbarung / Kooperationsplan',
erklaerung:
'Ein Vertrag zwischen Ihnen und dem Jobcenter. Darin steht, was Sie tun müssen (z. B. Bewerbungen schreiben) und wie das Jobcenter hilft.',
},
{
begriff: 'Vorsprache',
erklaerung: 'Sie sollen persönlich zum Amt kommen. Das ist ein Termin vor Ort.',
},
{
begriff: 'Nachweis',
erklaerung:
'Ein Papier, das etwas beweist. Zum Beispiel: eine Lohnabrechnung beweist Ihr Gehalt, ein Mietvertrag beweist Ihre Miete.',
},
{
begriff: 'Bedarfsgemeinschaft',
erklaerung:
'Alle Menschen, die zusammen in einer Wohnung leben und gemeinsam Geld vom Jobcenter bekommen — z. B. Partner und Kinder.',
},
{
begriff: 'Regelbedarf / Regelsatz',
erklaerung: 'Das Geld, das Sie jeden Monat für Essen, Kleidung und Alltag bekommen.',
},
{
begriff: 'Aufenthaltstitel',
erklaerung:
'Ein offizielles Dokument, das erlaubt, in Deutschland zu leben. Zum Beispiel: Visum, Aufenthaltserlaubnis, Niederlassungserlaubnis.',
},
{
begriff: 'Fiktionsbescheinigung',
erklaerung:
'Ein vorläufiges Papier von der Ausländerbehörde. Es gilt, solange über Ihren Antrag noch entschieden wird. Ihr Aufenthalt bleibt damit erlaubt.',
},
{
begriff: 'Duldung',
erklaerung:
'Sie müssen Deutschland eigentlich verlassen, aber die Abschiebung ist ausgesetzt. Die Duldung ist kein Aufenthaltstitel.',
},
{
begriff: 'Säumniszuschlag',
erklaerung:
'Extra-Geld, das Sie zahlen müssen, weil Sie zu spät gezahlt haben. Je später, desto teurer.',
},
{
begriff: 'Vollstreckung / Zwangsvollstreckung',
erklaerung:
'Wenn Sie nicht zahlen, holt sich das Amt das Geld mit Zwang — z. B. direkt vom Konto. Davor kommen Mahnungen. Reagieren Sie früh!',
},
{
begriff: 'Einspruch',
erklaerung:
'Wie ein Widerspruch, aber beim Finanzamt: Sie sagen schriftlich, dass Sie den Steuerbescheid falsch finden. Frist: meist ein Monat.',
},
{
begriff: 'Bestandskraft / rechtskräftig',
erklaerung:
'Die Entscheidung ist endgültig, weil die Frist für Widerspruch vorbei ist. Danach kann man kaum noch etwas ändern.',
},
{
begriff: 'Amtsgericht',
erklaerung: 'Das örtliche Gericht. Zuständig z. B. für Mahnbescheide, Familiensachen, kleinere Streitfälle.',
},
{
begriff: 'Mahnbescheid',
erklaerung:
'Ein gelber Brief vom Gericht: Jemand sagt, Sie schulden Geld. Sie haben 2 Wochen Zeit für Widerspruch. Nicht ignorieren — sonst wird es teuer!',
},
{
begriff: 'Ratenzahlung',
erklaerung:
'Sie zahlen eine Schuld in kleinen Teilen jeden Monat, statt alles auf einmal. Das können Sie beim Amt beantragen.',
},
{
begriff: 'Stundung',
erklaerung: 'Das Amt erlaubt Ihnen, später zu zahlen. Sie müssen das beantragen und begründen.',
},
];

View File

@@ -0,0 +1,331 @@
/**
* Ergebnis-Ansicht: Ampel, einfache Erklärung, Übersetzung, Fachbegriffe,
* Frist/Termin mit Kalender-Export, Checkliste und Antwort-Einstieg.
*/
import React, { useState } from 'react';
import {
Alert,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import * as Speech from 'expo-speech';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { RootStackParamList, SPRACHEN, Sprache } from '../types';
import { useAppStore } from '../store/useAppStore';
import { uebersetzeAnalyse } from '../services/uebersetzung';
import { ClaudeFehler } from '../services/claudeClient';
import { terminZumKalender } from '../services/kalender';
import { formatiereDatum } from '../services/erinnerungen';
import { berechneAmpel } from '../utils/ampel';
import { Ampel } from '../components/Ampel';
import { GrossButton } from '../components/GrossButton';
import { farben, schrift, abstand, TOUCH_TARGET } from '../theme';
type Props = NativeStackScreenProps<RootStackParamList, 'Analyse'>;
const DEUTSCH: Sprache = { code: 'de', name: 'Deutsch', eigenname: 'Deutsch' };
export function AnalyseScreen({ navigation, route }: Props) {
const brief = useAppStore((s) => s.briefe.find((b) => b.id === route.params.briefId));
const setzeUebersetzung = useAppStore((s) => s.setzeUebersetzung);
const removeBrief = useAppStore((s) => s.removeBrief);
const [sprache, setSprache] = useState<Sprache>(DEUTSCH);
const [sprachwahlOffen, setSprachwahlOffen] = useState(false);
const [uebersetzt, setUebersetzt] = useState(false);
const [liest, setLiest] = useState(false);
const [erledigt, setErledigt] = useState<Record<number, boolean>>({});
if (!brief) {
// Brief wurde gelöscht — zurück zum Start
return (
<View style={styles.zentriert}>
<Text style={styles.text}>Brief nicht gefunden.</Text>
</View>
);
}
const { analyse } = brief;
const ampel = berechneAmpel(analyse);
// Anzeige-Inhalte: deutsch oder aus dem Übersetzungs-Cache
const cache = sprache.code !== 'de' ? brief.uebersetzungen[sprache.code] : undefined;
const kernaussage = cache?.kernaussage ?? analyse.kernaussage;
const erklaerung = cache?.erklaerung_einfach ?? analyse.erklaerung_einfach;
const checkliste = cache?.checkliste ?? analyse.checkliste;
const fachbegriffe = cache?.fachbegriffe ?? analyse.fachbegriffe;
const sprscheWaehlen = async (neu: Sprache) => {
setSprachwahlOffen(false);
setSprache(neu);
if (neu.code === 'de' || brief.uebersetzungen[neu.code]) return;
// Noch nicht im Cache -> einmalig übersetzen
setUebersetzt(true);
try {
const u = await uebersetzeAnalyse(analyse, neu);
await setzeUebersetzung(brief.id, neu.code, u);
} catch (e) {
setSprache(DEUTSCH);
Alert.alert(
'Übersetzung fehlgeschlagen',
e instanceof ClaudeFehler ? e.message : 'Bitte versuchen Sie es erneut.'
);
} finally {
setUebersetzt(false);
}
};
/** Vorlese-Funktion für Menschen mit Leseschwäche. */
const vorlesen = () => {
if (liest) {
Speech.stop();
setLiest(false);
return;
}
setLiest(true);
Speech.speak(`${kernaussage}. ${erklaerung}`, {
language: sprache.code,
onDone: () => setLiest(false),
onStopped: () => setLiest(false),
onError: () => setLiest(false),
});
};
const kalenderExport = async () => {
if (!analyse.termin) return;
const ok = await terminZumKalender(analyse.termin, `Behörden-Termin: ${analyse.brieftyp}`);
Alert.alert(
ok ? 'Termin gespeichert' : 'Nicht möglich',
ok
? 'Der Termin wurde in Ihren Kalender eingetragen (mit Erinnerung 1 Tag vorher).'
: 'Bitte erlauben Sie den Kalender-Zugriff in den Geräte-Einstellungen.'
);
};
const loeschen = () => {
Alert.alert('Brief löschen?', 'Der Brief wird dauerhaft vom Gerät gelöscht.', [
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Löschen',
style: 'destructive',
onPress: async () => {
Speech.stop();
await removeBrief(brief.id);
navigation.popToTop();
},
},
]);
};
return (
<ScrollView style={styles.container} contentContainerStyle={{ padding: abstand.m }}>
{/* Dringlichkeits-Ampel ganz oben */}
<Ampel status={ampel} />
{/* Sprach-Auswahl */}
<Pressable
style={styles.sprachButton}
onPress={() => setSprachwahlOffen(true)}
accessibilityRole="button"
accessibilityLabel={`Sprache ändern. Aktuell: ${sprache.name}`}
>
<Text style={styles.sprachButtonText}>
🌐 {sprache.eigenname} {uebersetzt ? ' (übersetzt…)' : ''}
</Text>
</Pressable>
{/* Kernaussage: Was will das Amt von mir? */}
<View style={styles.karteWichtig}>
<Text style={styles.abschnittTitel}>Was will das Amt von mir?</Text>
<Text style={styles.kernaussage}>{kernaussage}</Text>
<View style={{ marginTop: abstand.s }}>
<GrossButton
titel={liest ? 'Vorlesen stoppen' : 'Vorlesen'}
symbol={liest ? '⏹️' : '🔊'}
variante="sekundaer"
onPress={vorlesen}
/>
</View>
</View>
{/* Frist / Termin */}
{(analyse.frist || analyse.termin) && (
<View style={[styles.karte, { borderColor: ampel.farbe, borderWidth: 2 }]}>
{analyse.frist && (
<Text style={styles.fristText}>
📅 Frist: <Text style={styles.fett}>{formatiereDatum(analyse.frist.datum)}</Text>
{'\n'}
{analyse.frist.aktion}
</Text>
)}
{analyse.termin && (
<>
<Text style={styles.fristText}>
🕐 Termin: <Text style={styles.fett}>{formatiereDatum(analyse.termin.datum)}</Text>
{analyse.termin.uhrzeit ? ` um ${analyse.termin.uhrzeit} Uhr` : ''}
{analyse.termin.ort ? `\n📍 ${analyse.termin.ort}` : ''}
</Text>
<GrossButton
titel="Termin zum Kalender hinzufügen"
symbol="🗓️"
variante="sekundaer"
onPress={kalenderExport}
/>
</>
)}
</View>
)}
{/* Ausführliche Erklärung */}
<View style={styles.karte}>
<Text style={styles.abschnittTitel}>Erklärung in einfacher Sprache</Text>
<Text style={styles.text}>{erklaerung}</Text>
</View>
{/* Checkliste */}
{checkliste.length > 0 && (
<View style={styles.karte}>
<Text style={styles.abschnittTitel}>Das müssen Sie tun</Text>
{checkliste.map((punkt, i) => (
<Pressable
key={i}
style={styles.checkZeile}
onPress={() => setErledigt((e) => ({ ...e, [i]: !e[i] }))}
accessibilityRole="checkbox"
accessibilityState={{ checked: !!erledigt[i] }}
>
<Text style={styles.checkBox}>{erledigt[i] ? '☑️' : '⬜'}</Text>
<Text style={[styles.text, { flex: 1 }, erledigt[i] && styles.durchgestrichen]}>
{punkt}
</Text>
</Pressable>
))}
</View>
)}
{/* Fachbegriffe */}
{fachbegriffe.length > 0 && (
<View style={styles.karte}>
<Text style={styles.abschnittTitel}>Schwere Wörter erklärt</Text>
{fachbegriffe.map((f, i) => (
<View key={i} style={styles.begriffBlock}>
<Text style={styles.begriff}>{f.begriff}</Text>
<Text style={styles.text}>{f.erklaerung}</Text>
</View>
))}
</View>
)}
{/* Antwort erstellen */}
{analyse.antwort_noetig && (
<GrossButton
titel="Antwort erstellen"
symbol="✍️"
onPress={() => navigation.navigate('Antwort', { briefId: brief.id })}
/>
)}
<View style={{ height: abstand.m }} />
<GrossButton titel="Brief löschen" symbol="🗑️" variante="gefahr" onPress={loeschen} />
<View style={{ height: abstand.xl }} />
{/* Sprach-Auswahl-Dialog */}
<Modal visible={sprachwahlOffen} transparent animationType="fade">
<Pressable style={styles.modalHintergrund} onPress={() => setSprachwahlOffen(false)}>
<View style={styles.modalKarte}>
<Text style={styles.abschnittTitel}>Sprache wählen</Text>
<ScrollView>
{[DEUTSCH, ...SPRACHEN].map((s) => (
<Pressable
key={s.code}
style={styles.sprachZeile}
onPress={() => sprscheWaehlen(s)}
accessibilityRole="button"
>
<Text
style={[
styles.sprachZeileText,
s.code === sprache.code && { fontWeight: '700', color: farben.primaer },
]}
>
{s.eigenname} {s.code !== 'de' ? `(${s.name})` : ''}
{brief.uebersetzungen[s.code] ? ' ✓' : ''}
</Text>
</Pressable>
))}
</ScrollView>
</View>
</Pressable>
</Modal>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: farben.hintergrund },
zentriert: { flex: 1, justifyContent: 'center', alignItems: 'center' },
karte: {
backgroundColor: farben.flaeche,
borderRadius: 14,
padding: abstand.m,
marginTop: abstand.m,
gap: abstand.s,
},
karteWichtig: {
backgroundColor: farben.flaeche,
borderRadius: 14,
borderWidth: 2,
borderColor: farben.primaer,
padding: abstand.m,
marginTop: abstand.m,
},
abschnittTitel: {
fontSize: schrift.gross,
fontWeight: '700',
color: farben.primaer,
marginBottom: abstand.xs,
},
kernaussage: { fontSize: schrift.gross, color: farben.text, lineHeight: 30 },
text: { fontSize: schrift.basis, color: farben.text, lineHeight: 27 },
fett: { fontWeight: '700' },
fristText: { fontSize: schrift.basis, color: farben.text, lineHeight: 28 },
checkZeile: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: abstand.s,
minHeight: 44,
paddingVertical: 4,
},
checkBox: { fontSize: 24 },
durchgestrichen: { textDecorationLine: 'line-through', color: farben.textSekundaer },
begriffBlock: { marginBottom: abstand.s },
begriff: { fontSize: schrift.basis, fontWeight: '700', color: farben.text },
sprachButton: {
marginTop: abstand.m,
minHeight: TOUCH_TARGET,
borderRadius: 14,
borderWidth: 2,
borderColor: farben.rand,
justifyContent: 'center',
paddingHorizontal: abstand.m,
},
sprachButtonText: { fontSize: schrift.basis, color: farben.text, fontWeight: '600' },
modalHintergrund: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
padding: abstand.l,
},
modalKarte: {
backgroundColor: farben.hintergrund,
borderRadius: 14,
padding: abstand.m,
maxHeight: '75%',
},
sprachZeile: { minHeight: TOUCH_TARGET, justifyContent: 'center' },
sprachZeileText: { fontSize: schrift.gross, color: farben.text },
});

View File

@@ -0,0 +1,221 @@
/**
* Antwort-Generator: Vorlage wählen -> KI-Entwurf -> frei bearbeiten ->
* als PDF exportieren oder als Text teilen.
*/
import React, { useState } from 'react';
import {
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
Share,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import * as Print from 'expo-print';
import * as Sharing from 'expo-sharing';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { AntwortTyp, ANTWORT_TYP_LABEL, RootStackParamList } from '../types';
import { useAppStore } from '../store/useAppStore';
import { generiereAntwort } from '../services/antwort';
import { ClaudeFehler } from '../services/claudeClient';
import { GrossButton } from '../components/GrossButton';
import { farben, schrift, abstand } from '../theme';
type Props = NativeStackScreenProps<RootStackParamList, 'Antwort'>;
const ALLE_TYPEN: AntwortTyp[] = [
'terminbestaetigung',
'terminverschiebung',
'widerspruch',
'unterlagen_nachreichen',
'rueckfrage',
];
export function AntwortScreen({ route }: Props) {
const brief = useAppStore((s) => s.briefe.find((b) => b.id === route.params.briefId));
const setzeAntwortEntwurf = useAppStore((s) => s.setzeAntwortEntwurf);
const [typ, setTyp] = useState<AntwortTyp | null>(brief?.antwortEntwurf?.typ ?? null);
const [hinweise, setHinweise] = useState('');
const [betreff, setBetreff] = useState(brief?.antwortEntwurf?.betreff ?? '');
const [text, setText] = useState(brief?.antwortEntwurf?.text ?? '');
const [laedt, setLaedt] = useState(false);
if (!brief) {
return (
<View style={styles.zentriert}>
<Text style={styles.text}>Brief nicht gefunden.</Text>
</View>
);
}
// Vom Brief vorgeschlagene Vorlagen zuerst, Rest danach
const optionen = [
...brief.analyse.antwort_optionen,
...ALLE_TYPEN.filter((t) => !brief.analyse.antwort_optionen.includes(t)),
];
const erzeugen = async () => {
if (!typ) return;
setLaedt(true);
try {
const entwurf = await generiereAntwort(brief.analyse, typ, hinweise);
setBetreff(entwurf.betreff);
setText(entwurf.text);
await setzeAntwortEntwurf(brief.id, { typ, ...entwurf });
} catch (e) {
Alert.alert(
'Fehler',
e instanceof ClaudeFehler ? e.message : 'Der Entwurf konnte nicht erstellt werden.'
);
} finally {
setLaedt(false);
}
};
/** Bearbeiteten Stand sichern (beim Export/Teilen automatisch). */
const sichern = async () => {
if (typ && text) {
await setzeAntwortEntwurf(brief.id, { typ, betreff, text });
}
};
const alsPdf = async () => {
await sichern();
try {
// Einfaches, sauberes Brief-Layout für den Ausdruck
const html = `
<html><head><meta charset="utf-8"><style>
body { font-family: Helvetica, Arial, sans-serif; font-size: 12pt; margin: 2.5cm; color: #111; }
.betreff { font-weight: bold; margin: 24pt 0 16pt 0; }
.text { white-space: pre-wrap; line-height: 1.5; }
</style></head><body>
<div class="betreff">${escapeHtml(betreff)}</div>
<div class="text">${escapeHtml(text)}</div>
</body></html>`;
const { uri } = await Print.printToFileAsync({ html });
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(uri, {
mimeType: 'application/pdf',
dialogTitle: 'Antwort als PDF',
});
}
} catch {
Alert.alert('Fehler', 'Das PDF konnte nicht erstellt werden.');
}
};
const alsText = async () => {
await sichern();
await Share.share({ message: `${betreff}\n\n${text}` });
};
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView style={styles.container} contentContainerStyle={{ padding: abstand.m }}>
<Text style={styles.abschnittTitel}>1. Was möchten Sie antworten?</Text>
<View style={styles.typListe}>
{optionen.map((t) => (
<GrossButton
key={t}
titel={
ANTWORT_TYP_LABEL[t] +
(brief.analyse.antwort_optionen.includes(t) ? ' ★' : '')
}
variante={typ === t ? 'primaer' : 'sekundaer'}
onPress={() => setTyp(t)}
/>
))}
</View>
<Text style={styles.abschnittTitel}>2. Zusätzliche Angaben (optional)</Text>
<TextInput
style={styles.eingabe}
placeholder="z. B. Wunschtermin, Grund der Verschiebung…"
placeholderTextColor={farben.textSekundaer}
value={hinweise}
onChangeText={setHinweise}
multiline
/>
<GrossButton
titel={text ? 'Neuen Entwurf erstellen' : 'Entwurf erstellen'}
symbol="✨"
onPress={erzeugen}
deaktiviert={!typ}
laedt={laedt}
/>
{text !== '' && (
<>
<Text style={styles.abschnittTitel}>3. Entwurf prüfen und anpassen</Text>
<Text style={styles.hinweis}>
Ersetzen Sie die Platzhalter in [eckigen Klammern] durch Ihre Daten.
</Text>
<TextInput
style={[styles.eingabe, styles.betreffEingabe]}
value={betreff}
onChangeText={setBetreff}
accessibilityLabel="Betreff"
/>
<TextInput
style={[styles.eingabe, styles.textEingabe]}
value={text}
onChangeText={setText}
multiline
textAlignVertical="top"
accessibilityLabel="Brieftext"
/>
<View style={{ gap: abstand.s }}>
<GrossButton titel="Als PDF exportieren" symbol="📄" onPress={alsPdf} />
<GrossButton titel="Als Text teilen / kopieren" symbol="📤" variante="sekundaer" onPress={alsText} />
</View>
</>
)}
<View style={{ height: abstand.xl }} />
</ScrollView>
</KeyboardAvoidingView>
);
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: farben.hintergrund },
zentriert: { flex: 1, justifyContent: 'center', alignItems: 'center' },
abschnittTitel: {
fontSize: schrift.gross,
fontWeight: '700',
color: farben.primaer,
marginTop: abstand.l,
marginBottom: abstand.s,
},
typListe: { gap: abstand.s },
eingabe: {
borderWidth: 1,
borderColor: farben.rand,
borderRadius: 12,
padding: abstand.s,
fontSize: schrift.basis,
color: farben.text,
backgroundColor: farben.hintergrund,
minHeight: 56,
marginBottom: abstand.m,
},
betreffEingabe: { fontWeight: '700' },
textEingabe: { minHeight: 320, lineHeight: 26 },
hinweis: { fontSize: schrift.klein, color: farben.textSekundaer, marginBottom: abstand.s },
text: { fontSize: schrift.basis, color: farben.text },
});

View File

@@ -0,0 +1,77 @@
/**
* Datenschutz-Einwilligung — MUSS vor dem ersten Scan bestätigt werden.
* Erklärt klar, dass der Briefinhalt zur KI-Analyse gesendet wird.
*/
import React from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { RootStackParamList } from '../types';
import { speichereConsent } from '../services/storage';
import { GrossButton } from '../components/GrossButton';
import { farben, schrift, abstand } from '../theme';
type Props = NativeStackScreenProps<RootStackParamList, 'Consent'>;
export function ConsentScreen({ navigation }: Props) {
const einverstanden = async () => {
await speichereConsent();
navigation.replace('Scan');
};
return (
<ScrollView style={styles.container} contentContainerStyle={styles.inhalt}>
<Text style={styles.titel}>Bevor es losgeht</Text>
<View style={styles.karte}>
<Text style={styles.punkt}>
📤 Wenn Sie einen Brief scannen, wird das Foto zur Analyse an den
KI-Dienst von Anthropic (USA) gesendet.
</Text>
<Text style={styles.punkt}>
🔒 Behördenbriefe enthalten sehr persönliche Daten (Name, Adresse,
Aktenzeichen, finanzielle Angaben). Senden Sie nur Briefe, deren
Analyse Sie wirklich möchten.
</Text>
<Text style={styles.punkt}>
📱 Die Analyse-Ergebnisse werden nur auf Ihrem Gerät gespeichert
nicht auf unseren Servern.
</Text>
<Text style={styles.punkt}>
🗑 Sie können in den Einstellungen jederzeit alle Daten vollständig
löschen.
</Text>
<Text style={styles.punkt}>
Die App gibt keine Rechtsberatung. Bei wichtigen Entscheidungen
(z. B. Widerspruch) fragen Sie eine Beratungsstelle oder einen Anwalt.
</Text>
</View>
<GrossButton titel="Ich bin einverstanden" onPress={einverstanden} />
<View style={{ height: abstand.s }} />
<GrossButton
titel="Abbrechen"
variante="sekundaer"
onPress={() => navigation.goBack()}
/>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: farben.hintergrund },
inhalt: { padding: abstand.l },
titel: {
fontSize: schrift.titel,
fontWeight: '700',
color: farben.text,
marginBottom: abstand.l,
},
karte: {
backgroundColor: farben.flaeche,
borderRadius: 14,
padding: abstand.m,
marginBottom: abstand.xl,
gap: abstand.m,
},
punkt: { fontSize: schrift.basis, color: farben.text, lineHeight: 27 },
});

View File

@@ -0,0 +1,137 @@
/**
* Einstellungen: API-Schlüssel (Dev-Modus), Datenschutz-Info,
* "Alle Daten löschen".
*/
import React, { useEffect, useState } from 'react';
import { Alert, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
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 { useAppStore } from '../store/useAppStore';
import { GrossButton } from '../components/GrossButton';
import { farben, schrift, abstand } from '../theme';
type Props = NativeStackScreenProps<RootStackParamList, 'Einstellungen'>;
export function EinstellungenScreen({ navigation }: Props) {
const leereAlles = useAppStore((s) => s.leereAlles);
const [apiKey, setApiKey] = useState('');
const [keyVorhanden, setKeyVorhanden] = useState(false);
const [gespeichert, setGespeichert] = useState(false);
useEffect(() => {
holeApiKey().then((k) => setKeyVorhanden(!!k));
}, []);
const speichern = async () => {
await speichereApiKey(apiKey);
setKeyVorhanden(!!apiKey.trim());
setApiKey('');
setGespeichert(true);
setTimeout(() => setGespeichert(false), 2500);
};
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.',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Alles löschen',
style: 'destructive',
onPress: async () => {
await loescheAlleDaten();
// Auch geplante Frist-Erinnerungen entfernen
await Notifications.cancelAllScheduledNotificationsAsync().catch(() => {});
leereAlles();
setKeyVorhanden(false);
Alert.alert('Erledigt', 'Alle Daten wurden gelöscht.');
navigation.popToTop();
},
},
]
);
};
return (
<ScrollView style={styles.container} contentContainerStyle={{ padding: abstand.m }}>
<Text style={styles.abschnittTitel}>KI-Zugang (API-Schlüssel)</Text>
<Text style={styles.text}>
Status:{' '}
<Text style={{ fontWeight: '700', color: keyVorhanden ? farben.ampelGruen : farben.fehler }}>
{keyVorhanden ? 'Schlüssel hinterlegt ✓' : 'Kein Schlüssel hinterlegt'}
</Text>
</Text>
<Text style={styles.hinweis}>
Der Schlüssel wird verschlüsselt auf dem Gerät gespeichert (Secure Store)
und nur für die Brief-Analyse verwendet. Einen Schlüssel erhalten Sie
unter console.anthropic.com.
</Text>
<TextInput
style={styles.eingabe}
placeholder="sk-ant-…"
placeholderTextColor={farben.textSekundaer}
value={apiKey}
onChangeText={setApiKey}
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
accessibilityLabel="API-Schlüssel eingeben"
/>
<GrossButton
titel={gespeichert ? 'Gespeichert ✓' : 'Schlüssel speichern'}
onPress={speichern}
deaktiviert={!apiKey.trim()}
/>
<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'}
Alle Ergebnisse liegen ausschließlich auf Ihrem Gerät.{'\n\n'}
Diese App ersetzt keine Rechtsberatung.
</Text>
</View>
<View style={styles.trenner} />
<Text style={styles.abschnittTitel}>Daten löschen</Text>
<GrossButton titel="Alle Daten löschen" symbol="🗑️" variante="gefahr" onPress={allesLoeschen} />
<View style={{ height: abstand.xl }} />
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: farben.hintergrund },
abschnittTitel: {
fontSize: schrift.gross,
fontWeight: '700',
color: farben.primaer,
marginBottom: abstand.s,
},
text: { fontSize: schrift.basis, color: farben.text, lineHeight: 27 },
hinweis: {
fontSize: schrift.klein,
color: farben.textSekundaer,
lineHeight: 22,
marginVertical: abstand.s,
},
eingabe: {
borderWidth: 1,
borderColor: farben.rand,
borderRadius: 12,
padding: abstand.s,
fontSize: schrift.basis,
color: farben.text,
minHeight: 56,
marginBottom: abstand.s,
},
karte: { backgroundColor: farben.flaeche, borderRadius: 14, padding: abstand.m },
trenner: { height: 1, backgroundColor: farben.rand, marginVertical: abstand.l },
});

View File

@@ -0,0 +1,76 @@
/**
* Behörden-Glossar: durchsuchbares Nachschlagewerk, komplett offline.
*/
import React, { useState } from 'react';
import { FlatList, StyleSheet, Text, TextInput, View } from 'react-native';
import { GLOSSAR } from '../data/glossar';
import { farben, schrift, abstand } from '../theme';
export function GlossarScreen() {
const [suche, setSuche] = useState('');
const gefiltert = GLOSSAR.filter(
(e) =>
!suche ||
e.begriff.toLowerCase().includes(suche.toLowerCase()) ||
e.erklaerung.toLowerCase().includes(suche.toLowerCase())
);
return (
<View style={styles.container}>
<TextInput
style={styles.suche}
placeholder="Begriff suchen, z. B. Widerspruch…"
placeholderTextColor={farben.textSekundaer}
value={suche}
onChangeText={setSuche}
accessibilityLabel="Glossar durchsuchen"
/>
<FlatList
data={gefiltert}
keyExtractor={(e) => e.begriff}
contentContainerStyle={{ padding: abstand.m, paddingTop: 0 }}
renderItem={({ item }) => (
<View style={styles.karte}>
<Text style={styles.begriff}>{item.begriff}</Text>
<Text style={styles.erklaerung}>{item.erklaerung}</Text>
</View>
)}
ListEmptyComponent={<Text style={styles.leer}>Kein Begriff gefunden.</Text>}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: farben.hintergrund },
suche: {
margin: abstand.m,
borderWidth: 1,
borderColor: farben.rand,
borderRadius: 12,
padding: abstand.s,
fontSize: schrift.basis,
color: farben.text,
minHeight: 48,
},
karte: {
backgroundColor: farben.flaeche,
borderRadius: 14,
padding: abstand.m,
marginBottom: abstand.s,
},
begriff: { fontSize: schrift.gross, fontWeight: '700', color: farben.primaer },
erklaerung: {
fontSize: schrift.basis,
color: farben.text,
lineHeight: 27,
marginTop: abstand.xs,
},
leer: {
textAlign: 'center',
fontSize: schrift.basis,
color: farben.textSekundaer,
marginTop: abstand.xl,
},
});

173
src/screens/HomeScreen.tsx Normal file
View File

@@ -0,0 +1,173 @@
/**
* Startbildschirm: großer Scan-Button + durchsuchbares Brief-Archiv.
*/
import React, { useState } from 'react';
import {
Alert,
FlatList,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { BriefEintrag, RootStackParamList } from '../types';
import { useAppStore } from '../store/useAppStore';
import { holeConsent } from '../services/storage';
import { berechneAmpel } from '../utils/ampel';
import { formatiereDatum } from '../services/erinnerungen';
import { GrossButton } from '../components/GrossButton';
import { farben, schrift, abstand } from '../theme';
type Props = NativeStackScreenProps<RootStackParamList, 'Home'>;
export function HomeScreen({ navigation }: Props) {
const briefe = useAppStore((s) => s.briefe);
const removeBrief = useAppStore((s) => s.removeBrief);
const [suche, setSuche] = useState('');
/** Scan starten — beim ersten Mal zuerst die Einwilligung einholen. */
const scanStarten = async () => {
const ok = await holeConsent();
navigation.navigate(ok ? 'Scan' : 'Consent');
};
const gefiltert = briefe.filter((b) => {
const s = suche.toLowerCase();
return (
!s ||
b.analyse.brieftyp.toLowerCase().includes(s) ||
b.analyse.absender.toLowerCase().includes(s) ||
b.analyse.kernaussage.toLowerCase().includes(s)
);
});
const loeschenBestaetigen = (brief: BriefEintrag) => {
Alert.alert(
'Brief löschen?',
`${brief.analyse.brieftyp}" wird dauerhaft vom Gerät gelöscht.`,
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Löschen', style: 'destructive', onPress: () => removeBrief(brief.id) },
]
);
};
const renderBrief = ({ item }: { item: BriefEintrag }) => {
const ampel = berechneAmpel(item.analyse);
return (
<Pressable
style={({ pressed }) => [styles.karte, pressed && { opacity: 0.7 }]}
onPress={() => navigation.navigate('Analyse', { briefId: item.id })}
onLongPress={() => loeschenBestaetigen(item)}
accessibilityRole="button"
accessibilityLabel={`Brief: ${item.analyse.brieftyp}. ${ampel.text}. Lange drücken zum Löschen.`}
>
<View style={[styles.ampelPunkt, { backgroundColor: ampel.farbe }]} />
<View style={{ flex: 1 }}>
<Text style={styles.kartenTitel} numberOfLines={1}>
{item.analyse.brieftyp}
</Text>
<Text style={styles.kartenUntertitel} numberOfLines={1}>
{item.analyse.absender} · {formatiereDatum(item.erstelltAm.slice(0, 10))}
</Text>
<Text style={[styles.kartenAmpelText, { color: ampel.farbe }]} numberOfLines={1}>
{ampel.text}
</Text>
</View>
<Text style={styles.pfeil}></Text>
</Pressable>
);
};
return (
<View style={styles.container}>
<View style={styles.kopf}>
<GrossButton titel="Brief scannen" symbol="📷" onPress={scanStarten} />
<View style={styles.reihe}>
<View style={{ flex: 1 }}>
<GrossButton
titel="Glossar"
variante="sekundaer"
symbol="📖"
onPress={() => navigation.navigate('Glossar')}
/>
</View>
<View style={{ width: abstand.s }} />
<View style={{ flex: 1 }}>
<GrossButton
titel="Einstellungen"
variante="sekundaer"
symbol="⚙️"
onPress={() => navigation.navigate('Einstellungen')}
/>
</View>
</View>
</View>
{briefe.length > 0 && (
<TextInput
style={styles.suche}
placeholder="Im Archiv suchen…"
placeholderTextColor={farben.textSekundaer}
value={suche}
onChangeText={setSuche}
accessibilityLabel="Archiv durchsuchen"
/>
)}
<FlatList
data={gefiltert}
keyExtractor={(b) => b.id}
renderItem={renderBrief}
contentContainerStyle={{ padding: abstand.m, paddingTop: 0 }}
ListEmptyComponent={
<Text style={styles.leer}>
{briefe.length === 0
? 'Noch keine Briefe.\nScannen Sie Ihren ersten Behördenbrief!'
: 'Keine Treffer.'}
</Text>
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: farben.hintergrund },
kopf: { padding: abstand.m, gap: abstand.s },
reihe: { flexDirection: 'row' },
suche: {
marginHorizontal: abstand.m,
marginBottom: abstand.s,
borderWidth: 1,
borderColor: farben.rand,
borderRadius: 12,
padding: abstand.s,
fontSize: schrift.basis,
color: farben.text,
minHeight: 48,
},
karte: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: farben.flaeche,
borderRadius: 14,
padding: abstand.m,
marginBottom: abstand.s,
gap: abstand.s,
},
ampelPunkt: { width: 16, height: 16, borderRadius: 8 },
kartenTitel: { fontSize: schrift.basis, fontWeight: '700', color: farben.text },
kartenUntertitel: { fontSize: schrift.klein, color: farben.textSekundaer, marginTop: 2 },
kartenAmpelText: { fontSize: schrift.klein, fontWeight: '600', marginTop: 2 },
pfeil: { fontSize: 32, color: farben.textSekundaer },
leer: {
textAlign: 'center',
fontSize: schrift.basis,
color: farben.textSekundaer,
marginTop: abstand.xl,
lineHeight: 28,
},
});

155
src/screens/ScanScreen.tsx Normal file
View File

@@ -0,0 +1,155 @@
/**
* 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 { ActivityIndicator, Alert, StyleSheet, Text, View } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import * as DocumentPicker from 'expo-document-picker';
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 { planeErinnerungen } from '../services/erinnerungen';
import { useAppStore, neueId } from '../store/useAppStore';
import { GrossButton } from '../components/GrossButton';
import { farben, schrift, abstand } from '../theme';
type Props = NativeStackScreenProps<RootStackParamList, 'Scan'>;
export function ScanScreen({ navigation }: Props) {
const addBrief = useAppStore((s) => s.addBrief);
const [laedt, setLaedt] = useState(false);
/** Gemeinsamer Abschluss: analysieren, speichern, Erinnerungen, weiter. */
const verarbeite = async (base64: string, mimeType: string) => {
setLaedt(true);
try {
const analyse = await analysiereBrief(base64, mimeType);
const brief: BriefEintrag = {
id: neueId(),
erstelltAm: new Date().toISOString(),
analyse,
uebersetzungen: {},
};
await addBrief(brief);
// Erinnerungen im Hintergrund planen — Fehler hier sind unkritisch
planeErinnerungen(brief).catch(() => {});
navigation.replace('Analyse', { briefId: brief.id });
} catch (e) {
const meldung =
e instanceof ClaudeFehler
? e.message
: 'Die Analyse ist fehlgeschlagen. Bitte versuchen Sie es erneut.';
Alert.alert('Fehler', meldung);
} finally {
setLaedt(false);
}
};
const fotoAufnehmen = async () => {
const { granted } = await ImagePicker.requestCameraPermissionsAsync();
if (!granted) {
Alert.alert('Kamera-Zugriff nötig', 'Bitte erlauben Sie den Kamera-Zugriff in den Geräte-Einstellungen.');
return;
}
const ergebnis = await ImagePicker.launchCameraAsync({
mediaTypes: 'images',
quality: 0.6, // Kompromiss: lesbar für die KI, aber kleine Datenmenge
base64: true,
});
if (!ergebnis.canceled && ergebnis.assets[0]?.base64) {
// base64 der ImagePicker-Assets ist laut Doku immer JPEG
await verarbeite(ergebnis.assets[0].base64, 'image/jpeg');
}
};
const ausGalerie = async () => {
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.');
return;
}
const ergebnis = await ImagePicker.launchImageLibraryAsync({
mediaTypes: 'images',
quality: 0.6,
base64: true,
});
if (!ergebnis.canceled && ergebnis.assets[0]?.base64) {
await verarbeite(ergebnis.assets[0].base64, 'image/jpeg');
}
};
const pdfWaehlen = async () => {
const ergebnis = await DocumentPicker.getDocumentAsync({
type: 'application/pdf',
copyToCacheDirectory: true,
});
if (ergebnis.canceled || !ergebnis.assets[0]) return;
try {
// Neues expo-file-system API (SDK 57): File-Klasse mit base64()
const datei = new File(ergebnis.assets[0].uri);
const base64 = await datei.base64();
await verarbeite(base64, 'application/pdf');
} catch {
Alert.alert('Fehler', 'Die PDF-Datei konnte nicht gelesen werden.');
}
};
if (laedt) {
return (
<View style={styles.ladeContainer}>
<ActivityIndicator size="large" color={farben.primaer} />
<Text style={styles.ladeText}>Ihr Brief wird analysiert</Text>
<Text style={styles.ladeHinweis}>
Das kann bis zu einer Minute dauern. Bitte lassen Sie die App geöffnet.
</Text>
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.anleitung}>
Fotografieren Sie den Brief gut beleuchtet und von oben. Der ganze Text
muss auf dem Foto sein.
</Text>
<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} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: farben.hintergrund,
padding: abstand.l,
gap: abstand.m,
justifyContent: 'center',
},
anleitung: {
fontSize: schrift.basis,
color: farben.textSekundaer,
textAlign: 'center',
lineHeight: 27,
marginBottom: abstand.l,
},
ladeContainer: {
flex: 1,
backgroundColor: farben.hintergrund,
justifyContent: 'center',
alignItems: 'center',
padding: abstand.xl,
gap: abstand.m,
},
ladeText: { fontSize: schrift.gross, fontWeight: '700', color: farben.text },
ladeHinweis: {
fontSize: schrift.basis,
color: farben.textSekundaer,
textAlign: 'center',
lineHeight: 26,
},
});

177
src/services/analyse.ts Normal file
View File

@@ -0,0 +1,177 @@
/**
* Brief-Analyse: Bild oder PDF -> strukturiertes JSON (BriefAnalyse).
* Ein einziger Claude-Vision-Aufruf extrahiert alles: Erklärung in einfacher
* Sprache, Fachbegriffe, Fristen, Termine, Checkliste und Antwort-Optionen.
*/
import { BriefAnalyse } from '../types';
import { claudeJsonAufruf, ContentBlock } from './claudeClient';
/**
* JSON-Schema für die Analyse. Die Claude API erzwingt dieses Schema
* (Structured Output) — die Antwort ist damit garantiert valides JSON
* in exakt dieser Form. Optionale Objekte (frist/termin) sind über
* anyOf [object, null] modelliert, da Structured Output
* additionalProperties: false und vollständige required-Listen verlangt.
*/
const ANALYSE_SCHEMA = {
type: 'object',
additionalProperties: false,
required: [
'brieftyp',
'absender',
'kernaussage',
'erklaerung_einfach',
'fachbegriffe',
'frist',
'termin',
'checkliste',
'antwort_noetig',
'antwort_optionen',
],
properties: {
brieftyp: {
type: 'string',
description:
'Kurze Kategorie des Briefs, z. B. "Einladung Jobcenter", "Bescheid Arbeitsagentur", "Steuerbescheid".',
},
absender: {
type: 'string',
description: 'Die Behörde, die den Brief geschickt hat.',
},
kernaussage: {
type: 'string',
description:
'Antwort auf "Was will das Amt von mir?" in 2-3 kurzen Sätzen, Sprachniveau A2.',
},
erklaerung_einfach: {
type: 'string',
description:
'Ausführlichere Erklärung des gesamten Briefs in einfacher Alltagssprache (A2/B1-Niveau). Kurze Sätze. Keine Fachbegriffe ohne Erklärung.',
},
fachbegriffe: {
type: 'array',
description: 'Alle Fachbegriffe/Amtsdeutsch-Wörter aus dem Brief mit einfacher Erklärung.',
items: {
type: 'object',
additionalProperties: false,
required: ['begriff', 'erklaerung'],
properties: {
begriff: { type: 'string' },
erklaerung: { type: 'string', description: 'Erklärung in einfacher Sprache (A2).' },
},
},
},
frist: {
description: 'Frist, bis wann der Nutzer reagieren muss. null wenn keine Frist im Brief steht.',
anyOf: [
{
type: 'object',
additionalProperties: false,
required: ['datum', 'aktion'],
properties: {
datum: { type: 'string', description: 'ISO-Format JJJJ-MM-TT.' },
aktion: { type: 'string', description: 'Was bis dahin passieren muss, z. B. "Unterlagen einreichen".' },
},
},
{ type: 'null' },
],
},
termin: {
description: 'Persönlicher Termin (Vorsprache o. Ä.). null wenn kein Termin im Brief steht.',
anyOf: [
{
type: 'object',
additionalProperties: false,
required: ['datum', 'uhrzeit', 'ort'],
properties: {
datum: { type: 'string', description: 'ISO-Format JJJJ-MM-TT.' },
uhrzeit: {
anyOf: [{ type: 'string' }, { type: 'null' }],
description: 'Format HH:MM, null wenn keine Uhrzeit genannt.',
},
ort: {
anyOf: [{ type: 'string' }, { type: 'null' }],
description: 'Adresse/Raum, null wenn nicht genannt.',
},
},
},
{ type: 'null' },
],
},
checkliste: {
type: 'array',
description:
'To-do-Liste für den Nutzer: was tun, was mitbringen, was einreichen. Leer wenn nichts zu tun ist.',
items: { type: 'string' },
},
antwort_noetig: {
type: 'boolean',
description: 'true wenn der Nutzer antworten/reagieren muss.',
},
antwort_optionen: {
type: 'array',
description: 'Welche Antwort-Vorlagen für diesen Brief sinnvoll sind.',
items: {
type: 'string',
enum: [
'terminbestaetigung',
'terminverschiebung',
'widerspruch',
'unterlagen_nachreichen',
'rueckfrage',
],
},
},
},
} as const;
const SYSTEM_PROMPT = `Du bist ein Assistent, der deutschen Behördenbriefe für Privatpersonen verständlich macht. Die Nutzer sind Deutsche, die Amtsdeutsch schwer verstehen, oder Menschen mit Deutsch als Fremdsprache.
Deine Aufgabe:
1. Lies den fotografierten/hochgeladenen Brief vollständig.
2. Erkläre ihn in einfacher Alltagssprache (Sprachniveau A2/B1): kurze Sätze, keine Schachtelsätze, keine unerklärten Fachbegriffe.
3. Extrahiere Fristen und Termine exakt. Datumsangaben immer als ISO-Format (JJJJ-MM-TT). Wenn du ein Datum nicht sicher lesen kannst, lass das Feld null — erfinde niemals Daten.
4. Erstelle eine konkrete Checkliste, was der Nutzer tun muss.
5. Sei sachlich und beruhigend, nicht alarmierend.
Wichtig: Wenn das Bild kein Behördenbrief ist oder unlesbar ist, schreibe das klar in kernaussage und erklaerung_einfach und lasse frist/termin null.`;
/**
* Analysiert ein Brief-Foto (JPEG/PNG als base64) oder ein PDF.
*
* @param base64 - Dateiinhalt als Base64-String (ohne data:-Präfix)
* @param mimeType - z. B. 'image/jpeg', 'image/png' oder 'application/pdf'
*/
export async function analysiereBrief(
base64: string,
mimeType: string
): Promise<BriefAnalyse> {
// PDF nutzt einen document-Block, Bilder einen image-Block
const dateiBlock: ContentBlock =
mimeType === 'application/pdf'
? {
type: 'document',
source: { type: 'base64', media_type: 'application/pdf', data: base64 },
}
: {
type: 'image',
source: { type: 'base64', media_type: mimeType, data: base64 },
};
return claudeJsonAufruf<BriefAnalyse>({
system: SYSTEM_PROMPT,
schema: ANALYSE_SCHEMA,
messages: [
{
role: 'user',
content: [
dateiBlock,
{
type: 'text',
text: `Analysiere diesen Behördenbrief. Heute ist der ${new Date().toISOString().slice(0, 10)} (wichtig für relative Datumsangaben wie "innerhalb von 14 Tagen").`,
},
],
},
],
});
}

70
src/services/antwort.ts Normal file
View File

@@ -0,0 +1,70 @@
/**
* Antwort-Generator: erzeugt einen formellen deutschen Antwort-Entwurf
* passend zum Brieftyp und zur gewählten Vorlage. Der Nutzer kann den
* Entwurf danach frei bearbeiten und als PDF exportieren.
*/
import { AntwortTyp, ANTWORT_TYP_LABEL, BriefAnalyse } from '../types';
import { claudeJsonAufruf } from './claudeClient';
const ANTWORT_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['betreff', 'text'],
properties: {
betreff: { type: 'string', description: 'Betreffzeile des Antwortbriefs.' },
text: {
type: 'string',
description:
'Vollständiger Brieftext inkl. Anrede und Grußformel. Platzhalter in eckigen Klammern für persönliche Daten, z. B. [Ihr Name], [Ihre Adresse], [Kundennummer].',
},
},
} as const;
export interface AntwortEntwurf {
betreff: string;
text: string;
}
/**
* @param analyse - Analyse des Original-Briefs (liefert Kontext)
* @param typ - gewählte Antwort-Vorlage
* @param hinweise - optionale Angaben des Nutzers (z. B. Wunschtermin, Grund)
*/
export async function generiereAntwort(
analyse: BriefAnalyse,
typ: AntwortTyp,
hinweise: string
): Promise<AntwortEntwurf> {
return claudeJsonAufruf<AntwortEntwurf>({
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.
- Persönliche Daten, die du nicht kennst, als Platzhalter in eckigen Klammern: [Ihr Name], [Ihre Adresse], [Aktenzeichen] usw.
- Aktenzeichen/Kundennummer aus dem Original-Brief referenzieren, falls bekannt.
- Bei Widersprüchen: sachlich, Frist wahren, Begründung ankündigen falls der Nutzer keine angegeben hat. Weise am Ende des Textes in einer Zeile "[Hinweis: ...]" darauf hin, dass bei rechtlichen Fragen eine Beratungsstelle oder ein Anwalt helfen kann — die App ersetzt keine Rechtsberatung.`,
schema: ANTWORT_SCHEMA,
messages: [
{
role: 'user',
content: `Schreibe eine Antwort auf diesen Behördenbrief.
Art der Antwort: ${ANTWORT_TYP_LABEL[typ]}
Analyse des Original-Briefs:
${JSON.stringify(
{
brieftyp: analyse.brieftyp,
absender: analyse.absender,
kernaussage: analyse.kernaussage,
frist: analyse.frist,
termin: analyse.termin,
},
null,
2
)}
Zusätzliche Angaben des Nutzers: ${hinweise.trim() || 'keine'}`,
},
],
});
}

View File

@@ -0,0 +1,186 @@
/**
* Low-Level-Client für die Claude API (Anthropic).
*
* Zwei Betriebsmodi:
* 1. DEV (Standard): Direkter Aufruf der Anthropic-API mit dem API-Key aus
* expo-secure-store (Nutzer trägt ihn in den Einstellungen ein).
* 2. PROD: PROXY_URL setzen — dann laufen alle Anfragen über den eigenen
* Server, der den API-Key hält. Endkunden brauchen dann keinen Key.
* Der Proxy muss denselben Request-Body akzeptieren und die
* Anthropic-Antwort unverändert durchreichen.
*
* Hinweis: Das offizielle @anthropic-ai/sdk unterstützt React Native nicht,
* deshalb hier bewusst rohes fetch gegen die Messages API.
*/
import * as Network from 'expo-network';
import { holeApiKey } 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;
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';
// ---- Typen für den Request-Body (Teilmenge der Messages API) ----
export interface TextBlock {
type: 'text';
text: string;
}
export interface ImageBlock {
type: 'image';
source: { type: 'base64'; media_type: string; data: string };
}
export interface DocumentBlock {
type: 'document';
source: { type: 'base64'; media_type: 'application/pdf'; data: string };
}
export type ContentBlock = TextBlock | ImageBlock | DocumentBlock;
export interface ClaudeRequest {
system?: string;
messages: { role: 'user' | 'assistant'; content: string | ContentBlock[] }[];
/** JSON-Schema für Structured Output — garantiert valides JSON in der Antwort. */
schema?: object;
maxTokens?: number;
}
/** Fehler mit nutzerfreundlicher deutscher Meldung. */
export class ClaudeFehler extends Error {
constructor(meldung: string, public readonly technisch?: string) {
super(meldung);
this.name = 'ClaudeFehler';
}
}
/**
* Führt einen Claude-Aufruf aus und gibt das geparste JSON-Objekt zurück.
* Nur für Aufrufe mit `schema` (Structured Output) gedacht — alle
* BehördenKlar-Aufrufe nutzen Structured Output.
*/
export async function claudeJsonAufruf<T>(anfrage: ClaudeRequest): Promise<T> {
// 1. Offline-Check mit klarer Meldung (Anforderung: Offline-Hinweis)
const netz = await Network.getNetworkStateAsync();
if (!netz.isConnected || netz.isInternetReachable === false) {
throw new ClaudeFehler(
'Keine Internetverbindung. Bitte verbinden Sie sich mit dem Internet und versuchen Sie es erneut.'
);
}
// 2. Request-Body bauen
const body: Record<string, unknown> = {
model: MODELL,
max_tokens: anfrage.maxTokens ?? 16000,
// Adaptives Denken verbessert die Extraktions-Genauigkeit deutlich
thinking: { type: 'adaptive' },
messages: anfrage.messages,
};
if (anfrage.system) body.system = anfrage.system;
if (anfrage.schema) {
body.output_config = {
format: { type: 'json_schema', schema: anfrage.schema },
};
}
// 3. Endpunkt + Header je nach Modus
let url = ANTHROPIC_URL;
const headers: Record<string, string> = {
'content-type': 'application/json',
'anthropic-version': ANTHROPIC_VERSION,
};
if (PROXY_URL) {
url = PROXY_URL; // Der Proxy hält den API-Key serverseitig
} else {
const apiKey = await holeApiKey();
if (!apiKey) {
throw new ClaudeFehler(
'Kein API-Schlüssel hinterlegt. Bitte tragen Sie Ihren Anthropic-API-Schlüssel in den Einstellungen ein.'
);
}
headers['x-api-key'] = apiKey;
}
// 4. Aufruf
let antwort: Response;
try {
antwort = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
} catch (e) {
throw new ClaudeFehler(
'Verbindung fehlgeschlagen. Bitte prüfen Sie Ihre Internetverbindung.',
String(e)
);
}
// 5. HTTP-Fehler in verständliche Meldungen übersetzen
if (!antwort.ok) {
let technisch = '';
try {
const fehlerJson = await antwort.json();
technisch = fehlerJson?.error?.message ?? '';
} catch {
/* Fehler-Body war kein JSON */
}
switch (antwort.status) {
case 401:
throw new ClaudeFehler(
'Der API-Schlüssel ist ungültig. Bitte in den Einstellungen prüfen.',
technisch
);
case 429:
throw new ClaudeFehler(
'Zu viele Anfragen. Bitte warten Sie einen Moment und versuchen Sie es erneut.',
technisch
);
case 413:
throw new ClaudeFehler(
'Das Bild ist zu groß. Bitte fotografieren Sie den Brief erneut.',
technisch
);
default:
if (antwort.status >= 500) {
throw new ClaudeFehler(
'Der KI-Dienst ist gerade nicht erreichbar. Bitte später erneut versuchen.',
technisch
);
}
throw new ClaudeFehler(
'Die Analyse ist fehlgeschlagen. Bitte versuchen Sie es erneut.',
`HTTP ${antwort.status}: ${technisch}`
);
}
}
const daten = await antwort.json();
// 6. Stop-Reason prüfen (Sicherheits-Ablehnung / abgeschnittene Antwort)
if (daten.stop_reason === 'refusal') {
throw new ClaudeFehler(
'Die KI konnte diesen Inhalt nicht verarbeiten. Bitte prüfen Sie, ob das Foto wirklich einen Behördenbrief zeigt.'
);
}
if (daten.stop_reason === 'max_tokens') {
throw new ClaudeFehler(
'Der Brief ist sehr lang — die Analyse wurde abgeschnitten. Bitte fotografieren Sie ggf. nur die wichtigste Seite.'
);
}
// 7. Structured Output: der letzte Text-Block enthält garantiert valides JSON
// (davor können Thinking-Blöcke stehen — deshalb nicht content[0] nehmen)
const textBlock = [...(daten.content ?? [])]
.reverse()
.find((b: { type: string }) => b.type === 'text');
if (!textBlock) {
throw new ClaudeFehler('Die KI hat keine verwertbare Antwort geliefert.');
}
return JSON.parse(textBlock.text) as T;
}

View File

@@ -0,0 +1,105 @@
/**
* Lokale Erinnerungen vor ablaufenden Fristen und Terminen.
* Nutzt lokal geplante Benachrichtigungen (expo-notifications) —
* funktioniert ohne Push-Server. Hinweis: In Expo Go auf Android sind
* Benachrichtigungen eingeschränkt; im Development Build voll verfügbar.
*/
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
import { BriefEintrag } from '../types';
/** Muss einmalig beim App-Start laufen (siehe App.tsx). */
export function initialisiereBenachrichtigungen(): void {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
}
async function stelleKanalUndRechteSicher(): Promise<boolean> {
try {
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('fristen', {
name: 'Fristen und Termine',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
});
}
const { granted } = await Notifications.requestPermissionsAsync();
return granted;
} catch {
return false;
}
}
/** Plant eine Benachrichtigung um 9:00 Uhr am angegebenen Tag (falls in der Zukunft). */
async function planeUm9Uhr(datumIso: string, titel: string, text: string): Promise<void> {
const datum = new Date(`${datumIso}T09:00:00`);
if (isNaN(datum.getTime()) || datum.getTime() <= Date.now()) return;
await Notifications.scheduleNotificationAsync({
content: { title: titel, body: text, sound: true },
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: datum,
channelId: 'fristen',
},
});
}
/** Verschiebt ein ISO-Datum um n Tage. */
function tageVorher(datumIso: string, tage: number): string {
const d = new Date(`${datumIso}T00:00:00`);
d.setDate(d.getDate() - tage);
return d.toISOString().slice(0, 10);
}
/**
* Plant Erinnerungen für einen Brief: je 3 Tage und 1 Tag vor Frist/Termin.
* Vergangene Zeitpunkte werden übersprungen. Fehler (z. B. verweigerte
* Berechtigung) brechen die App nicht — Erinnerungen sind ein Bonus.
*
* @returns true wenn mindestens eine Erinnerung geplant wurde
*/
export async function planeErinnerungen(brief: BriefEintrag): Promise<boolean> {
const ok = await stelleKanalUndRechteSicher();
if (!ok) return false;
let geplant = false;
try {
const { frist, termin, brieftyp } = brief.analyse;
if (frist) {
for (const abstand of [3, 1]) {
await planeUm9Uhr(
tageVorher(frist.datum, abstand),
`Frist in ${abstand} Tag${abstand > 1 ? 'en' : ''}: ${brieftyp}`,
`Bis ${formatiereDatum(frist.datum)}: ${frist.aktion}`
);
geplant = true;
}
}
if (termin) {
for (const abstand of [3, 1]) {
await planeUm9Uhr(
tageVorher(termin.datum, abstand),
`Termin in ${abstand} Tag${abstand > 1 ? 'en' : ''}: ${brieftyp}`,
`Am ${formatiereDatum(termin.datum)}${termin.uhrzeit ? ` um ${termin.uhrzeit} Uhr` : ''}${termin.ort ? `${termin.ort}` : ''}`
);
geplant = true;
}
}
} catch {
return geplant;
}
return geplant;
}
/** ISO-Datum -> deutsches Format TT.MM.JJJJ. */
export function formatiereDatum(iso: string): string {
const [j, m, t] = iso.split('-');
if (!j || !m || !t) return iso;
return `${t}.${m}.${j}`;
}

52
src/services/kalender.ts Normal file
View File

@@ -0,0 +1,52 @@
/**
* Termin-Export in den Geräte-Kalender (expo-calendar).
*/
import * as Calendar from 'expo-calendar';
import { Platform } from 'react-native';
import { Termin } from '../types';
async function holeKalenderId(): Promise<string | null> {
const { granted } = await Calendar.requestCalendarPermissionsAsync();
if (!granted) return null;
if (Platform.OS === 'ios') {
const standard = await Calendar.getDefaultCalendarAsync();
return standard?.id ?? null;
}
// Android: ersten beschreibbaren Kalender nehmen
const kalender = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT);
const beschreibbar = kalender.find((k) => k.allowsModifications);
return beschreibbar?.id ?? null;
}
/**
* Legt den Behörden-Termin im Kalender an.
* @returns true bei Erfolg, false wenn Berechtigung fehlt / kein Kalender.
*/
export async function terminZumKalender(
termin: Termin,
titel: string
): Promise<boolean> {
try {
const kalenderId = await holeKalenderId();
if (!kalenderId) return false;
// Startzeit: genannte Uhrzeit oder 09:00 als Standard
const uhrzeit = termin.uhrzeit ?? '09:00';
const start = new Date(`${termin.datum}T${uhrzeit}:00`);
if (isNaN(start.getTime())) return false;
const ende = new Date(start.getTime() + 60 * 60 * 1000); // 1 Stunde
await Calendar.createEventAsync(kalenderId, {
title: titel,
startDate: start,
endDate: ende,
location: termin.ort ?? undefined,
notes: 'Erstellt mit BehördenKlar',
alarms: [{ relativeOffset: -60 * 24 }], // Erinnerung 1 Tag vorher
});
return true;
} catch {
return false;
}
}

64
src/services/storage.ts Normal file
View File

@@ -0,0 +1,64 @@
/**
* Lokale Speicherung.
* - API-Key: expo-secure-store (verschlüsselt, nie im Klartext auf der Platte)
* - Brief-Archiv + Einwilligung: AsyncStorage (bleibt auf dem Gerät)
*
* Datenschutz: Alle Daten bleiben lokal. Nichts wird an Server gesendet,
* außer dem Briefinhalt zur KI-Analyse (nach expliziter Einwilligung).
* loescheAlleDaten() entfernt restlos alles.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as SecureStore from 'expo-secure-store';
import { BriefEintrag } from '../types';
const KEY_API = 'anthropic_api_key';
const KEY_BRIEFE = 'behoerdenklar_briefe';
const KEY_CONSENT = 'behoerdenklar_consent';
// ---- API-Key (sensibel -> SecureStore) ----
export async function holeApiKey(): Promise<string | null> {
return SecureStore.getItemAsync(KEY_API);
}
export async function speichereApiKey(key: string): Promise<void> {
if (key.trim()) {
await SecureStore.setItemAsync(KEY_API, key.trim());
} else {
await SecureStore.deleteItemAsync(KEY_API);
}
}
// ---- Einwilligung (Datenschutz-Consent) ----
export async function holeConsent(): Promise<boolean> {
return (await AsyncStorage.getItem(KEY_CONSENT)) === 'ja';
}
export async function speichereConsent(): Promise<void> {
await AsyncStorage.setItem(KEY_CONSENT, 'ja');
}
// ---- Brief-Archiv ----
export async function ladeBriefe(): Promise<BriefEintrag[]> {
const roh = await AsyncStorage.getItem(KEY_BRIEFE);
if (!roh) return [];
try {
return JSON.parse(roh) as BriefEintrag[];
} catch {
// Korrupte Daten nicht crashen lassen — leeres Archiv liefern
return [];
}
}
export async function speichereBriefe(briefe: BriefEintrag[]): Promise<void> {
await AsyncStorage.setItem(KEY_BRIEFE, JSON.stringify(briefe));
}
// ---- Alles löschen (Datenschutz-Anforderung) ----
export async function loescheAlleDaten(): Promise<void> {
await AsyncStorage.multiRemove([KEY_BRIEFE, KEY_CONSENT]);
await SecureStore.deleteItemAsync(KEY_API);
}

View File

@@ -0,0 +1,57 @@
/**
* Übersetzung der Analyse in eine Zielsprache — on demand, eine Sprache
* pro Aufruf (spart Tokens). Ergebnisse werden pro Brief+Sprache im
* Archiv gecacht (siehe store), damit kein Aufruf doppelt läuft.
*/
import { BriefAnalyse, Sprache, Uebersetzung } from '../types';
import { claudeJsonAufruf } from './claudeClient';
const UEBERSETZUNG_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['kernaussage', 'erklaerung_einfach', 'checkliste', 'fachbegriffe'],
properties: {
kernaussage: { type: 'string' },
erklaerung_einfach: { type: 'string' },
checkliste: { type: 'array', items: { type: 'string' } },
fachbegriffe: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['begriff', 'erklaerung'],
properties: {
// Der deutsche Begriff bleibt stehen (steht so im Brief!),
// nur die Erklärung wird übersetzt.
begriff: { type: 'string' },
erklaerung: { type: 'string' },
},
},
},
},
} as const;
export async function uebersetzeAnalyse(
analyse: BriefAnalyse,
sprache: Sprache
): Promise<Uebersetzung> {
return claudeJsonAufruf<Uebersetzung>({
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: [
{
role: 'user',
content: `Übersetze die folgenden Felder nach ${sprache.name} (${sprache.code}):\n\n${JSON.stringify(
{
kernaussage: analyse.kernaussage,
erklaerung_einfach: analyse.erklaerung_einfach,
checkliste: analyse.checkliste,
fachbegriffe: analyse.fachbegriffe,
},
null,
2
)}`,
},
],
});
}

71
src/store/useAppStore.ts Normal file
View File

@@ -0,0 +1,71 @@
/**
* Globaler App-State (Zustand). Hält das Brief-Archiv im Speicher und
* synchronisiert jede Änderung sofort nach AsyncStorage.
*/
import { create } from 'zustand';
import { BriefEintrag, Uebersetzung, AntwortTyp } from '../types';
import { ladeBriefe, speichereBriefe } from '../services/storage';
interface AppState {
briefe: BriefEintrag[];
geladen: boolean;
/** Archiv beim App-Start aus AsyncStorage laden. */
initialisiere: () => Promise<void>;
addBrief: (brief: BriefEintrag) => Promise<void>;
removeBrief: (id: string) => Promise<void>;
/** Übersetzung in den Cache des Briefs schreiben. */
setzeUebersetzung: (id: string, sprachCode: string, u: Uebersetzung) => Promise<void>;
setzeAntwortEntwurf: (
id: string,
entwurf: { typ: AntwortTyp; betreff: string; text: string }
) => Promise<void>;
leereAlles: () => void;
}
export const useAppStore = create<AppState>((set, get) => ({
briefe: [],
geladen: false,
initialisiere: async () => {
const briefe = await ladeBriefe();
set({ briefe, geladen: true });
},
addBrief: async (brief) => {
const briefe = [brief, ...get().briefe]; // neueste zuerst
set({ briefe });
await speichereBriefe(briefe);
},
removeBrief: async (id) => {
const briefe = get().briefe.filter((b) => b.id !== id);
set({ briefe });
await speichereBriefe(briefe);
},
setzeUebersetzung: async (id, sprachCode, u) => {
const briefe = get().briefe.map((b) =>
b.id === id
? { ...b, uebersetzungen: { ...b.uebersetzungen, [sprachCode]: u } }
: b
);
set({ briefe });
await speichereBriefe(briefe);
},
setzeAntwortEntwurf: async (id, entwurf) => {
const briefe = get().briefe.map((b) =>
b.id === id ? { ...b, antwortEntwurf: entwurf } : b
);
set({ briefe });
await speichereBriefe(briefe);
},
leereAlles: () => set({ briefe: [] }),
}));
/** Einfache eindeutige ID ohne externe Abhängigkeit. */
export function neueId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}

45
src/theme.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* Design-System: ruhig, vertrauenswürdig, barrierefrei.
* - Große Schrift (Basis 18) für die Zielgruppe
* - Hoher Kontrast (dunkles Blau auf Weiß, WCAG AA)
* - Große Touch-Targets (min. 56px Höhe)
*/
export const farben = {
// Grundfarben
hintergrund: '#FFFFFF',
flaeche: '#F2F5F9',
primaer: '#1A365D', // dunkles, ruhiges Blau
primaerText: '#FFFFFF',
text: '#1A202C',
textSekundaer: '#4A5568',
rand: '#CBD5E0',
// Dringlichkeits-Ampel (kräftig, gut unterscheidbar)
ampelRot: '#C0392B',
ampelGelb: '#B7791F',
ampelGruen: '#276749',
ampelRotHintergrund: '#FDECEA',
ampelGelbHintergrund: '#FDF6E3',
ampelGruenHintergrund: '#EAF6EE',
fehler: '#C0392B',
};
export const schrift = {
klein: 15,
basis: 18,
gross: 21,
titel: 26,
riesig: 32,
};
export const abstand = {
xs: 6,
s: 12,
m: 16,
l: 24,
xl: 32,
};
/** Mindesthöhe für alle interaktiven Elemente (Barrierefreiheit). */
export const TOUCH_TARGET = 56;

116
src/types.ts Normal file
View File

@@ -0,0 +1,116 @@
/**
* Zentrale Typdefinitionen für BehördenKlar.
* Die Feldnamen entsprechen exakt dem JSON-Schema, das die Claude API
* per Structured Output garantiert zurückliefert (siehe services/analyse.ts).
*/
/** Ampel-Stufen für die Dringlichkeit eines Briefs. */
export type Dringlichkeit = 'rot' | 'gelb' | 'gruen';
/** Vorlagen-Typen für den Antwort-Generator. */
export type AntwortTyp =
| 'terminbestaetigung'
| 'terminverschiebung'
| 'widerspruch'
| 'unterlagen_nachreichen'
| 'rueckfrage';
export const ANTWORT_TYP_LABEL: Record<AntwortTyp, string> = {
terminbestaetigung: 'Termin bestätigen',
terminverschiebung: 'Termin verschieben',
widerspruch: 'Widerspruch einlegen',
unterlagen_nachreichen: 'Unterlagen nachreichen',
rueckfrage: 'Rückfrage stellen',
};
/** Ein im Brief gefundener Fachbegriff mit einfacher Erklärung. */
export interface Fachbegriff {
begriff: string;
erklaerung: string;
}
/** Eine erkannte Frist ("Antwort bis TT.MM.JJJJ"). Datum ist ISO (JJJJ-MM-TT). */
export interface Frist {
datum: string;
aktion: string;
}
/** Ein erkannter Termin (z. B. Vorsprache beim Amt). Datum ist ISO. */
export interface Termin {
datum: string;
uhrzeit: string | null;
ort: string | null;
}
/** Das strukturierte Analyse-Ergebnis eines gescannten Briefs. */
export interface BriefAnalyse {
brieftyp: string;
absender: string;
/** "Was will das Amt von mir?" — 2-3 Sätze, A2-Niveau. */
kernaussage: string;
/** Ausführlichere Erklärung in einfacher Sprache (A2/B1). */
erklaerung_einfach: string;
fachbegriffe: Fachbegriff[];
frist: Frist | null;
termin: Termin | null;
/** To-do-Liste: was der Nutzer tun / mitbringen muss. */
checkliste: string[];
antwort_noetig: boolean;
antwort_optionen: AntwortTyp[];
}
/** Übersetzung der wichtigsten Analyse-Teile in eine Zielsprache. */
export interface Uebersetzung {
kernaussage: string;
erklaerung_einfach: string;
checkliste: string[];
fachbegriffe: Fachbegriff[];
}
/** Ein gespeicherter Brief im lokalen Archiv. */
export interface BriefEintrag {
id: string;
/** ISO-Zeitstempel der Erstellung. */
erstelltAm: string;
analyse: BriefAnalyse;
/** Cache: Sprachcode -> fertige Übersetzung (spart API-Kosten). */
uebersetzungen: Record<string, Uebersetzung>;
antwortEntwurf?: {
typ: AntwortTyp;
betreff: string;
text: string;
};
}
/** Eine unterstützte Zielsprache für die Übersetzung. */
export interface Sprache {
/** ISO-Code, wird an die KI übergeben. */
code: string;
/** Deutscher Name (für die UI). */
name: string;
/** Name in der Sprache selbst (damit Nutzer ihre Sprache erkennen). */
eigenname: string;
}
export const SPRACHEN: Sprache[] = [
{ code: 'tr', name: 'Türkisch', eigenname: 'Türkçe' },
{ code: 'ar', name: 'Arabisch', eigenname: 'العربية' },
{ code: 'en', name: 'Englisch', eigenname: 'English' },
{ code: 'ru', name: 'Russisch', eigenname: 'Русский' },
{ code: 'uk', name: 'Ukrainisch', eigenname: 'Українська' },
{ code: 'fr', name: 'Französisch', eigenname: 'Français' },
{ code: 'fa', name: 'Farsi', eigenname: 'فارسی' },
{ code: 'ro', name: 'Rumänisch', eigenname: 'Română' },
{ code: 'pl', name: 'Polnisch', eigenname: 'Polski' },
];
/** Navigation: Parameter aller Screens. */
export type RootStackParamList = {
Home: undefined;
Consent: undefined;
Scan: undefined;
Analyse: { briefId: string };
Antwort: { briefId: string };
Glossar: undefined;
Einstellungen: undefined;
};

89
src/utils/ampel.ts Normal file
View File

@@ -0,0 +1,89 @@
/**
* Dringlichkeits-Ampel — bewusst LOKAL berechnet, nicht von der KI:
* deterministisch, kostenlos, und immer aktuell relativ zu "heute"
* (ein archivierter Brief wird automatisch roter, je näher die Frist rückt).
*
* 🔴 Rot = Frist/Termin in < 7 Tagen (oder bereits abgelaufen)
* 🟡 Gelb = Frist/Termin in 7-21 Tagen
* 🟢 Grün = keine Frist / rein informativ
*/
import { BriefAnalyse, Dringlichkeit } from '../types';
import { farben } from '../theme';
export interface AmpelStatus {
stufe: Dringlichkeit;
/** Kurztext für die UI, z. B. "Frist in 3 Tagen". */
text: string;
farbe: string;
hintergrund: string;
}
/** Ganze Tage von heute bis zum ISO-Datum (negativ = vergangen). */
export function tageBis(datumIso: string): number {
const heute = new Date();
heute.setHours(0, 0, 0, 0);
const ziel = new Date(`${datumIso}T00:00:00`);
return Math.round((ziel.getTime() - heute.getTime()) / 86400000);
}
export function berechneAmpel(analyse: BriefAnalyse): AmpelStatus {
const daten: { tage: number; label: string }[] = [];
if (analyse.frist) {
daten.push({ tage: tageBis(analyse.frist.datum), label: 'Frist' });
}
if (analyse.termin) {
daten.push({ tage: tageBis(analyse.termin.datum), label: 'Termin' });
}
if (daten.length === 0) {
return {
stufe: 'gruen',
text: 'Keine Frist — nur zur Information',
farbe: farben.ampelGruen,
hintergrund: farben.ampelGruenHintergrund,
};
}
// Das dringendste (kleinste) Datum bestimmt die Ampel
const dringendstes = daten.reduce((a, b) => (a.tage <= b.tage ? a : b));
const { tage, label } = dringendstes;
if (tage < 0) {
return {
stufe: 'rot',
text: `${label} abgelaufen (vor ${Math.abs(tage)} Tag${Math.abs(tage) === 1 ? '' : 'en'})! Schnell handeln.`,
farbe: farben.ampelRot,
hintergrund: farben.ampelRotHintergrund,
};
}
if (tage === 0) {
return {
stufe: 'rot',
text: `${label} ist HEUTE!`,
farbe: farben.ampelRot,
hintergrund: farben.ampelRotHintergrund,
};
}
if (tage < 7) {
return {
stufe: 'rot',
text: `${label} in ${tage} Tag${tage === 1 ? '' : 'en'} — dringend!`,
farbe: farben.ampelRot,
hintergrund: farben.ampelRotHintergrund,
};
}
if (tage <= 21) {
return {
stufe: 'gelb',
text: `${label} in ${tage} Tagen — bald erledigen`,
farbe: farben.ampelGelb,
hintergrund: farben.ampelGelbHintergrund,
};
}
return {
stufe: 'gruen',
text: `${label} in ${tage} Tagen — genug Zeit`,
farbe: farben.ampelGruen,
hintergrund: farben.ampelGruenHintergrund,
};
}

6
tsconfig.json Normal file
View File

@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}