1 version

This commit is contained in:
JUSN committed 2026-07-12 19:59:15 +02:00
1 parent f1165b5404
commit 88c49e1b54
39 files changed
+9328 -52

No files matched your search

+331
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 },
});
+221
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 },
});
+77
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 },
});
+137
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 },
});
+76
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
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
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,
},
});