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