generated from knso/webklar-preview-template
1 version
This commit is contained in:
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"expo@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
43
.gitignore
vendored
43
.gitignore
vendored
@@ -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
3
AGENTS.md
Normal 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
53
App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
112
README.md
@@ -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 · 🟡 7–21 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
44
app.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
assets/android-icon-background.png
Normal file
BIN
assets/android-icon-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/android-icon-foreground.png
Normal file
BIN
assets/android-icon-foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
BIN
assets/android-icon-monochrome.png
Normal file
BIN
assets/android-icon-monochrome.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/favicon.png
Normal file
BIN
assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
BIN
assets/splash-icon.png
Normal file
BIN
assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
8
index.ts
Normal file
8
index.ts
Normal 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
6600
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
Normal file
39
package.json
Normal 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
36
src/components/Ampel.tsx
Normal 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' },
|
||||
});
|
||||
76
src/components/GrossButton.tsx
Normal file
76
src/components/GrossButton.tsx
Normal 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
131
src/data/glossar.ts
Normal 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.',
|
||||
},
|
||||
];
|
||||
331
src/screens/AnalyseScreen.tsx
Normal file
331
src/screens/AnalyseScreen.tsx
Normal 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 },
|
||||
});
|
||||
221
src/screens/AntwortScreen.tsx
Normal file
221
src/screens/AntwortScreen.tsx
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
77
src/screens/ConsentScreen.tsx
Normal file
77
src/screens/ConsentScreen.tsx
Normal 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 },
|
||||
});
|
||||
137
src/screens/EinstellungenScreen.tsx
Normal file
137
src/screens/EinstellungenScreen.tsx
Normal 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 },
|
||||
});
|
||||
76
src/screens/GlossarScreen.tsx
Normal file
76
src/screens/GlossarScreen.tsx
Normal 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
173
src/screens/HomeScreen.tsx
Normal 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
155
src/screens/ScanScreen.tsx
Normal 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
177
src/services/analyse.ts
Normal 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
70
src/services/antwort.ts
Normal 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'}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
186
src/services/claudeClient.ts
Normal file
186
src/services/claudeClient.ts
Normal 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;
|
||||
}
|
||||
105
src/services/erinnerungen.ts
Normal file
105
src/services/erinnerungen.ts
Normal 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
52
src/services/kalender.ts
Normal 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
64
src/services/storage.ts
Normal 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);
|
||||
}
|
||||
57
src/services/uebersetzung.ts
Normal file
57
src/services/uebersetzung.ts
Normal 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
71
src/store/useAppStore.ts
Normal 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
45
src/theme.ts
Normal 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
116
src/types.ts
Normal 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
89
src/utils/ampel.ts
Normal 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
6
tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user