Files
beh-rde-brief/src/screens/AnalyseScreen.tsx
T
JUSNandClaude Opus 5.5 0571fc01eb App-Design wie die Webseite: Papier, Tinte, Amtsblau, Textmarker, Georgia
- theme.ts nimmt die Werte der Webseite (--papier, --tinte, --amtsblau,
  --marker, --stempelrot) statt Gold/Bronze; neue Bausteine schriftart
  (Georgia / Courier), ecke, kartenSchatten, formularRahmen, etikett
- alle Texte in Georgia, Versal-Etiketten in Schreibmaschinenschrift (grau
  wie "BETREFF"), Karten als Papierblaetter mit feiner Linie + flachem Schatten
- Knoepfe wie .cta: Amtsblau bzw. weisses Blatt, Tinten-Rahmen, harter
  Versatz-Schatten, beim Druecken rutscht der Knopf in den Schatten
- Startseite: Briefkopf "BehoerdenKlar by Webklar" mit Tinten-Linie,
  Textmarker auf "Fristen", dringendste Frist als Formular
- Tab-Leiste: Papier mit Tinten-Linie, Scan-Knopf mit hartem Schatten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:27:02 +02:00

409 lines
18 KiB
TypeScript

/**
* Brief-Detail ("Ihr Brief erklärt") — Layout nach dem Claude-Design-Entwurf:
* große Frist-Karte mit Countdown oben, dann "Das ist passiert",
* "Das müssen Sie tun", schwere Wörter und der Einstieg zur Antwort.
* Alle Funktionen bleiben: Übersetzung, Vorlesen, Kalender, Löschen.
*/
import React, { useLayoutEffect, 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 { berechneAmpel, naechsteFrist, formatiereLangDatum } from '../utils/ampel';
import { GrossButton } from '../components/GrossButton';
import { Ikone } from '../components/Ikone';
import { farben, schrift, abstand, TOUCH_TARGET, schriftart, ecke, kartenSchatten } from '../theme';
type Props = NativeStackScreenProps<RootStackParamList, 'Analyse'>;
const DEUTSCH: Sprache = { code: 'de', name: 'Deutsch', eigenname: 'Deutsch' };
const STUFEN_LABEL: Record<string, string> = {
rot: 'Dringend',
gelb: 'Handlung nötig',
gruen: 'Zur Kenntnis',
};
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>>({});
// Header-Titel = Brieftyp (wie im Entwurf), sobald der Brief geladen ist
useLayoutEffect(() => {
if (brief) navigation.setOptions({ title: brief.analyse.brieftyp });
}, [navigation, brief]);
if (!brief) {
return (
<View style={styles.zentriert}>
<Text style={styles.text}>Brief nicht gefunden.</Text>
</View>
);
}
const { analyse } = brief;
const ampel = berechneAmpel(analyse);
const frist = naechsteFrist(analyse);
const stufenLabel = STUFEN_LABEL[ampel.stufe];
// 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;
// Arabisch, Farsi, Urdu, Paschtu laufen von rechts nach links. Ohne das
// steht der übersetzte Text linksbündig und liest sich falsch.
const rtl = !!sprache.rtl;
const rtlText = rtl ? ({ writingDirection: 'rtl', textAlign: 'right' } as const) : null;
const sprscheWaehlen = async (neu: Sprache) => {
setSprachwahlOffen(false);
setSprache(neu);
if (neu.code === 'de' || brief.uebersetzungen[neu.code]) return;
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);
}
};
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, paddingBottom: abstand.xl }}>
{/* ── Frist-Karte mit Countdown ── */}
{frist ? (
<View style={[styles.fristKarte, { backgroundColor: ampel.hintergrund, borderColor: ampel.farbe }]}>
<View style={styles.fristKopf}>
<Ikone name="uhr" groesse={16} farbe={ampel.farbe} />
<Text style={[styles.fristKicker, { color: ampel.farbe }]}>
{frist.label} · {stufenLabel}
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'flex-end', gap: 12 }}>
<Text style={[styles.grosseZahl, { color: ampel.farbe }]}>
{frist.tage < 0 ? '!' : frist.tage}
</Text>
<View style={{ paddingBottom: 8 }}>
<Text style={[styles.zahlEinheit, { color: ampel.farbe }]}>
{frist.tage < 0 ? 'überfällig' : 'Tage'}
</Text>
<Text style={styles.zahlUnter}>{frist.tage < 0 ? '' : 'verbleiben'}</Text>
</View>
</View>
<View style={styles.trenner} />
<View style={styles.datenZeile}>
<Text style={styles.datenLabel}>{frist.label === 'Termin' ? 'Termin am' : 'Fällig am'}</Text>
<Text style={styles.datenWert}>{formatiereLangDatum(frist.datumIso)}</Text>
</View>
{analyse.frist?.aktion && (
<View style={styles.datenZeile}>
<Text style={styles.datenLabel}>Zu tun</Text>
<Text style={[styles.datenWert, { color: ampel.farbe, flex: 1, textAlign: 'right' }]}>
{analyse.frist.aktion}
</Text>
</View>
)}
{analyse.termin && (
<>
{(analyse.termin.uhrzeit || analyse.termin.ort) && (
<View style={styles.datenZeile}>
<Text style={styles.datenLabel}>Wo & wann</Text>
<Text style={[styles.datenWert, { flex: 1, textAlign: 'right' }]}>
{analyse.termin.uhrzeit ? `${analyse.termin.uhrzeit} Uhr` : ''}
{analyse.termin.uhrzeit && analyse.termin.ort ? ' · ' : ''}
{analyse.termin.ort ?? ''}
</Text>
</View>
)}
<View style={{ marginTop: abstand.s }}>
<GrossButton
titel="Termin zum Kalender hinzufügen"
ikone="kalender"
variante="sekundaer"
onPress={kalenderExport}
/>
</View>
</>
)}
</View>
) : (
<View style={styles.keineFrist}>
<Ikone name="info" groesse={20} farbe={farben.ampelGruen} />
<View style={{ flex: 1 }}>
<Text style={styles.keineFristTitel}>Keine Frist</Text>
<Text style={styles.keineFristText}>Dieser Brief ist nur zur Kenntnis. Sie müssen nichts tun.</Text>
</View>
</View>
)}
{/* ── Steuerung: Sprache + Vorlesen ── */}
<View style={styles.werkzeugZeile}>
<Pressable
style={styles.werkzeug}
onPress={() => setSprachwahlOffen(true)}
accessibilityRole="button"
accessibilityLabel={`Sprache ändern. Aktuell: ${sprache.name}`}
>
<Ikone name="globus" groesse={18} farbe={farben.primaer} />
<Text style={styles.werkzeugText} numberOfLines={1}>
{uebersetzt ? 'übersetzt…' : sprache.eigenname}
</Text>
</Pressable>
<Pressable style={styles.werkzeug} onPress={vorlesen} accessibilityRole="button">
<Ikone name={liest ? 'stopp' : 'vorlesen'} groesse={18} farbe={farben.primaer} />
<Text style={styles.werkzeugText}>{liest ? 'Stopp' : 'Vorlesen'}</Text>
</Pressable>
</View>
{/* ── Das ist passiert ── */}
<Text style={styles.kicker}>Das ist passiert</Text>
<Text style={[styles.kernaussage, rtlText]}>{kernaussage}</Text>
<Text style={[styles.erklaerung, rtlText]}>{erklaerung}</Text>
{/* ── Das müssen Sie tun ── */}
{checkliste.length > 0 && (
<>
<View style={styles.dünnerTrenner} />
<Text style={styles.kicker}>Das müssen Sie tun</Text>
<View style={{ gap: abstand.s }}>
{checkliste.map((punkt, i) => {
const fertig = !!erledigt[i];
return (
<Pressable
key={i}
style={[styles.todoZeile, rtl && { flexDirection: 'row-reverse' as const }]}
onPress={() => setErledigt((e) => ({ ...e, [i]: !e[i] }))}
accessibilityRole="checkbox"
accessibilityState={{ checked: fertig }}
>
<View style={[styles.todoKreis, fertig && styles.todoKreisFertig]}>
{fertig ? (
<Ikone name="checkAn" groesse={16} farbe={farben.primaerText} />
) : (
<Text style={styles.todoNummer}>{i + 1}</Text>
)}
</View>
<Text style={[styles.todoText, fertig && styles.durchgestrichen, rtlText]}>{punkt}</Text>
</Pressable>
);
})}
</View>
</>
)}
{/* ── Schwere Wörter ── */}
{fachbegriffe.length > 0 && (
<>
<View style={styles.dünnerTrenner} />
<Text style={styles.kicker}>Schwere Wörter erklärt</Text>
<View style={{ gap: abstand.s }}>
{fachbegriffe.map((f, i) => (
<View key={i} style={styles.begriffKarte}>
<Text style={styles.begriff}>{f.begriff}</Text>
<Text style={[styles.begriffText, rtlText]}>{f.erklaerung}</Text>
</View>
))}
</View>
</>
)}
{/* ── Antwort ── */}
{analyse.antwort_noetig && (
<View style={{ marginTop: abstand.l }}>
<GrossButton
titel="Antwort erstellen"
ikone="stift"
onPress={() => navigation.navigate('Antwort', { briefId: brief.id })}
/>
</View>
)}
{/* ── In der Beratung: Brief auf dem Bildschirm zeigen ── */}
<View style={{ marginTop: abstand.l }}>
<GrossButton
titel="Am Computer zeigen"
ikone="computer"
variante="sekundaer"
onPress={() => navigation.navigate('Verbinden', { modus: 'senden', briefId: brief.id })}
/>
</View>
{/* ── Datenschutz-Fuß + Löschen ── */}
<View style={styles.schutzZeile}>
<Ikone name="schloss" groesse={13} farbe={farben.textTertiaer} />
<Text style={styles.schutzText}>Auf Ihrem Gerät ausgewertet · verschlüsselt gespeichert</Text>
</View>
<View style={{ marginTop: abstand.m }}>
<GrossButton titel="Brief löschen" ikone="muell" variante="gefahr" onPress={loeschen} />
</View>
{/* Sprach-Auswahl-Dialog */}
<Modal visible={sprachwahlOffen} transparent animationType="fade">
<Pressable style={styles.modalHintergrund} onPress={() => setSprachwahlOffen(false)}>
<View style={styles.modalKarte}>
<Text style={styles.modalTitel}>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.primaerFuellung },
]}
>
{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' },
// Frist-Karte
fristKarte: { borderRadius: ecke, borderWidth: 1, padding: abstand.l },
fristKopf: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 10 },
fristKicker: { fontFamily: schriftart.mono, fontSize: 12, letterSpacing: 0.6, textTransform: 'uppercase', fontWeight: '700' },
grosseZahl: { fontFamily: schriftart.serif, fontSize: 64, lineHeight: 64, fontWeight: '800', fontVariant: ['tabular-nums'] },
zahlEinheit: { fontFamily: schriftart.serif, fontSize: 22, fontWeight: '700', lineHeight: 24 },
zahlUnter: { fontFamily: schriftart.serif, fontSize: 13, color: farben.textSekundaer },
trenner: { height: 1, backgroundColor: farben.rand, marginVertical: 14 },
datenZeile: { flexDirection: 'row', justifyContent: 'space-between', gap: 12, marginTop: 5 },
datenLabel: { fontFamily: schriftart.serif, fontSize: schrift.klein, color: farben.textSekundaer },
datenWert: { fontFamily: schriftart.serif, fontSize: schrift.klein, fontWeight: '700', color: farben.text },
keineFrist: {
flexDirection: 'row', gap: abstand.s, alignItems: 'center',
backgroundColor: farben.ampelGruenHintergrund, borderRadius: ecke,
borderLeftWidth: 4, borderLeftColor: farben.ampelGruen, padding: abstand.m,
},
keineFristTitel: { fontFamily: schriftart.serif, fontSize: schrift.basis, fontWeight: '700', color: farben.text },
keineFristText: { fontFamily: schriftart.serif, fontSize: schrift.klein, color: farben.textSekundaer, marginTop: 2 },
// Werkzeuge
werkzeugZeile: { flexDirection: 'row', gap: abstand.s, marginTop: abstand.m },
werkzeug: { ...kartenSchatten,
flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 7,
backgroundColor: farben.flaeche, borderRadius: ecke, minHeight: 48, paddingHorizontal: abstand.s,
},
werkzeugText: { fontFamily: schriftart.serif, fontSize: schrift.klein, fontWeight: '600', color: farben.primaerFuellung },
kicker: { fontFamily: schriftart.mono,
fontSize: 12, letterSpacing: 1.1, textTransform: 'uppercase',
color: farben.textSekundaer, fontWeight: '700', marginTop: abstand.l, marginBottom: abstand.xs,
},
kernaussage: { fontFamily: schriftart.serif, fontSize: schrift.gross, color: farben.text, lineHeight: 29, fontWeight: '600' },
erklaerung: { fontFamily: schriftart.serif, fontSize: schrift.basis, color: farben.textSekundaer, lineHeight: 27, marginTop: abstand.s },
dünnerTrenner: { height: 1, backgroundColor: farben.rand, marginTop: abstand.l },
// To-do
todoZeile: { flexDirection: 'row', gap: abstand.s, alignItems: 'flex-start', minHeight: 40 },
todoKreis: {
width: 28, height: 28, borderRadius: ecke, borderWidth: 1.5, borderColor: farben.primaer,
alignItems: 'center', justifyContent: 'center', marginTop: 1,
},
todoKreisFertig: { backgroundColor: farben.ampelGruen, borderColor: farben.ampelGruen },
todoNummer: { fontFamily: schriftart.serif, fontSize: schrift.klein, fontWeight: '700', color: farben.primaerFuellung, fontVariant: ['tabular-nums'] },
todoText: { fontFamily: schriftart.serif, flex: 1, fontSize: schrift.basis, color: farben.text, lineHeight: 25, paddingTop: 3 },
durchgestrichen: { textDecorationLine: 'line-through', color: farben.textSekundaer },
// Fachbegriffe
begriffKarte: { ...kartenSchatten, backgroundColor: farben.flaeche, borderRadius: ecke, padding: abstand.m },
begriff: { fontFamily: schriftart.serif, fontSize: schrift.basis, fontWeight: '700', color: farben.text, marginBottom: 3 },
begriffText: { fontFamily: schriftart.serif, fontSize: schrift.klein, color: farben.textSekundaer, lineHeight: 23 },
schutzZeile: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: abstand.l },
schutzText: { fontFamily: schriftart.serif, fontSize: 11.5, color: farben.textTertiaer },
text: { fontFamily: schriftart.serif, fontSize: schrift.basis, color: farben.text, lineHeight: 27 },
// Modal
modalHintergrund: { flex: 1, backgroundColor: 'rgba(0,0,0,0.45)', justifyContent: 'center', padding: abstand.l },
modalKarte: { ...kartenSchatten, backgroundColor: farben.flaeche, borderRadius: ecke, padding: abstand.m, maxHeight: '75%' },
modalTitel: { fontFamily: schriftart.serif, fontSize: schrift.gross, fontWeight: '700', color: farben.text, marginBottom: abstand.xs },
sprachZeile: { minHeight: TOUCH_TARGET, justifyContent: 'center' },
sprachZeileText: { fontFamily: schriftart.serif, fontSize: schrift.gross, color: farben.text },
});