1 version

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

View File

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

View File

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

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

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

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

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

View File

@@ -0,0 +1,57 @@
/**
* Übersetzung der Analyse in eine Zielsprache — on demand, eine Sprache
* pro Aufruf (spart Tokens). Ergebnisse werden pro Brief+Sprache im
* Archiv gecacht (siehe store), damit kein Aufruf doppelt läuft.
*/
import { BriefAnalyse, Sprache, Uebersetzung } from '../types';
import { claudeJsonAufruf } from './claudeClient';
const UEBERSETZUNG_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['kernaussage', 'erklaerung_einfach', 'checkliste', 'fachbegriffe'],
properties: {
kernaussage: { type: 'string' },
erklaerung_einfach: { type: 'string' },
checkliste: { type: 'array', items: { type: 'string' } },
fachbegriffe: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['begriff', 'erklaerung'],
properties: {
// Der deutsche Begriff bleibt stehen (steht so im Brief!),
// nur die Erklärung wird übersetzt.
begriff: { type: 'string' },
erklaerung: { type: 'string' },
},
},
},
},
} as const;
export async function uebersetzeAnalyse(
analyse: BriefAnalyse,
sprache: Sprache
): Promise<Uebersetzung> {
return claudeJsonAufruf<Uebersetzung>({
system: `Du übersetzt Erklärungen deutscher Behördenbriefe in andere Sprachen. Übersetze in einfache, klare Sprache (Niveau A2/B1 der Zielsprache). Deutsche Fachbegriffe (Feld "begriff") NICHT übersetzen — sie stehen so im Original-Brief und der Nutzer muss sie wiedererkennen. Nur die Erklärungen übersetzen.`,
schema: UEBERSETZUNG_SCHEMA,
messages: [
{
role: 'user',
content: `Übersetze die folgenden Felder nach ${sprache.name} (${sprache.code}):\n\n${JSON.stringify(
{
kernaussage: analyse.kernaussage,
erklaerung_einfach: analyse.erklaerung_einfach,
checkliste: analyse.checkliste,
fachbegriffe: analyse.fachbegriffe,
},
null,
2
)}`,
},
],
});
}

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

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

45
src/theme.ts Normal file
View File

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

116
src/types.ts Normal file
View File

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

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

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