App-Redesign Phase 1: Fristen-Start + Tab-Leiste (Struktur aus Postklar-Design)

Uebernimmt den Aufbau des Claude-Design-Entwurfs, umgesetzt im bestehenden
lesbaren iOS-Look (System-Schrift, iOS-Blau) statt der Serifenschrift.

- Neue untere Tab-Leiste: Fristen - Scannen (erhabener Knopf) - Archiv
  (@react-navigation/bottom-tabs; Root-Stack mit Detail-Screens darueber)
- FristenScreen (neuer Start): Briefe nach Dringlichkeit statt Datum,
  naechste Frist gross mit Countdown, weitere Fristen, Info-Briefe,
  Datenschutz-Hinweis; nutzt echte Archivdaten
- ArchivScreen: Suche + Behoerden-Filter-Chips (aus echten Absendern),
  uebernimmt die Listenrolle vom alten Start
- ampel.ts: naechsteFrist() + formatiereLangDatum() (Countdown/Sortierung)
- Ikone.tsx: uhr, archiv, schloss, lupe, pfeilLinks ergaenzt
- alten HomeScreen entfernt; types: HauptTabParamList, Root 'Tabs'

Typecheck gruen, 30 Tests gruen. Visuelle Pruefung im Simulator steht aus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 21:06:31 +02:00
parent b27c69c4bd
commit ed627bc10e
9 changed files with 751 additions and 270 deletions

View File

@@ -27,7 +27,12 @@ export type IkonenName =
| 'warnung'
| 'wecker'
| 'info'
| 'funken';
| 'funken'
| 'uhr'
| 'archiv'
| 'schloss'
| 'lupe'
| 'pfeilLinks';
// `as const` hält die Namen als Literal-Typen — so prüft TypeScript gegen
// die offizielle SF-Symbol-/Material-Namensliste von expo-symbols
@@ -52,6 +57,11 @@ const NAMEN = {
wecker: { ios: 'alarm.fill', android: 'alarm' },
info: { ios: 'info.circle.fill', android: 'info' },
funken: { ios: 'wand.and.stars', android: 'auto_awesome' },
uhr: { ios: 'clock.fill', android: 'schedule' },
archiv: { ios: 'archivebox.fill', android: 'inventory_2' },
schloss: { ios: 'lock.fill', android: 'lock' },
lupe: { ios: 'magnifyingglass', android: 'search' },
pfeilLinks: { ios: 'chevron.left', android: 'chevron_left' },
} as const;
interface Props {

View File

@@ -0,0 +1,188 @@
/**
* Archiv — alle Briefe durchsuchbar und nach Behörde filterbar.
* (Übernimmt die Listen-/Suchfunktion, die vorher auf dem Start-Screen lag.)
*/
import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BriefEintrag, RootStackParamList } from '../types';
import { useAppStore } from '../store/useAppStore';
import { berechneAmpel } from '../utils/ampel';
import { formatiereDatum } from '../services/erinnerungen';
import { Ikone } from '../components/Ikone';
import { farben, schrift, abstand, TOUCH_TARGET } from '../theme';
type Nav = NativeStackNavigationProp<RootStackParamList>;
/** Kurzschlüssel einer Behörde: erstes Wort des Absenders (z. B. „Jobcenter"). */
function behoerdenSchluessel(absender: string): string {
return absender.trim().split(/\s+/)[0] || absender;
}
export function ArchivScreen() {
const navigation = useNavigation<Nav>();
const insets = useSafeAreaInsets();
const briefe = useAppStore((s) => s.briefe);
const removeBrief = useAppStore((s) => s.removeBrief);
const [suche, setSuche] = useState('');
const [behoerde, setBehoerde] = useState('alle');
// Behörden-Chips aus den echten Absendern ableiten
const behoerden = Array.from(new Set(briefe.map((b) => behoerdenSchluessel(b.analyse.absender))));
const filter = ['alle', ...behoerden];
const gefiltert = briefe.filter((b) => {
const s = suche.trim().toLowerCase();
const passtBehoerde = behoerde === 'alle' || behoerdenSchluessel(b.analyse.absender) === behoerde;
const passtSuche =
!s ||
b.analyse.brieftyp.toLowerCase().includes(s) ||
b.analyse.absender.toLowerCase().includes(s) ||
b.analyse.kernaussage.toLowerCase().includes(s);
return passtBehoerde && passtSuche;
});
const loeschen = (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) },
]
);
};
return (
<ScrollView
style={styles.container}
contentContainerStyle={{ paddingTop: insets.top + 12, paddingHorizontal: abstand.m, paddingBottom: abstand.xl }}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.kicker}>Archiv</Text>
<Text style={styles.titel}>Alle Briefe</Text>
{/* Suche */}
<View style={styles.sucheBox}>
<Ikone name="lupe" groesse={17} farbe={farben.textTertiaer} />
<TextInput
style={styles.sucheFeld}
placeholder="Behörde, Brieftyp oder Inhalt suchen"
placeholderTextColor={farben.textTertiaer}
value={suche}
onChangeText={setSuche}
accessibilityLabel="Archiv durchsuchen"
/>
</View>
{/* Behörden-Filter */}
{behoerden.length > 1 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ marginBottom: abstand.m }}
contentContainerStyle={{ gap: 8, paddingRight: abstand.m }}
>
{filter.map((f) => {
const aktiv = behoerde === f;
return (
<Pressable
key={f}
onPress={() => setBehoerde(f)}
accessibilityRole="button"
style={[styles.chip, aktiv && styles.chipAktiv]}
>
<Text style={[styles.chipText, aktiv && styles.chipTextAktiv]}>
{f === 'alle' ? 'Alle' : f}
</Text>
</Pressable>
);
})}
</ScrollView>
)}
<Text style={styles.zahlZeile}>
{gefiltert.length} Brief{gefiltert.length === 1 ? '' : 'e'}
</Text>
{gefiltert.length === 0 ? (
<View style={styles.leerBox}>
<Text style={styles.leerTitel}>{briefe.length === 0 ? 'Noch keine Briefe' : 'Kein Treffer'}</Text>
<Text style={styles.leerText}>
{briefe.length === 0
? 'Gescannte Briefe erscheinen hier — durchsuchbar und sicher auf Ihrem Gerät.'
: 'Für Ihre Suche wurde nichts gefunden.'}
</Text>
</View>
) : (
<View>
{gefiltert.map((b) => {
const ampel = berechneAmpel(b.analyse);
return (
<Pressable
key={b.id}
onPress={() => navigation.navigate('Analyse', { briefId: b.id })}
onLongPress={() => loeschen(b)}
accessibilityRole="button"
accessibilityLabel={`Brief: ${b.analyse.brieftyp}. ${ampel.text}. Lange drücken zum Löschen.`}
style={({ pressed }) => [styles.zeile, pressed && { opacity: 0.7 }]}
>
<View style={[styles.punkt, { backgroundColor: ampel.farbe }]} />
<View style={{ flex: 1, minWidth: 0 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', gap: 10 }}>
<Text style={styles.zeileTyp} numberOfLines={1}>{b.analyse.brieftyp}</Text>
<Text style={styles.datum}>{formatiereDatum(b.erstelltAm.slice(0, 10))}</Text>
</View>
<Text style={styles.zeileMeta} numberOfLines={1}>{b.analyse.absender}</Text>
<Text style={[styles.ampelText, { color: ampel.farbe }]} numberOfLines={1}>{ampel.text}</Text>
</View>
<Ikone name="pfeilRechts" groesse={16} farbe={farben.textTertiaer} />
</Pressable>
);
})}
</View>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: farben.hintergrund },
kicker: {
fontSize: 12, letterSpacing: 1.3, textTransform: 'uppercase',
color: farben.primaer, fontWeight: '600', marginBottom: 4,
},
titel: { fontSize: schrift.riesig, fontWeight: '800', color: farben.text, marginBottom: abstand.m },
sucheBox: {
flexDirection: 'row', alignItems: 'center', gap: 8,
backgroundColor: farben.flaeche, borderRadius: 12,
paddingHorizontal: abstand.s, marginBottom: abstand.s, minHeight: 48,
},
sucheFeld: { flex: 1, fontSize: schrift.basis, color: farben.text, paddingVertical: abstand.s },
chip: {
borderRadius: 9999, borderWidth: 1, borderColor: farben.rand,
backgroundColor: farben.flaeche, paddingHorizontal: 14, paddingVertical: 8,
},
chipAktiv: { borderColor: farben.primaer, backgroundColor: farben.hervorhebung },
chipText: { fontSize: schrift.klein, color: farben.textSekundaer },
chipTextAktiv: { color: farben.primaer, fontWeight: '600' },
zahlZeile: {
fontSize: 12, letterSpacing: 1, textTransform: 'uppercase',
color: farben.textTertiaer, marginBottom: abstand.s,
},
zeile: {
flexDirection: 'row', alignItems: 'center', gap: abstand.s,
backgroundColor: farben.flaeche, borderRadius: 12,
padding: abstand.m, marginBottom: abstand.s, minHeight: TOUCH_TARGET,
},
punkt: { width: 10, height: 10, borderRadius: 5 },
zeileTyp: { flex: 1, fontSize: schrift.basis, fontWeight: '700', color: farben.text },
datum: { fontSize: schrift.klein, color: farben.textTertiaer, fontVariant: ['tabular-nums'] },
zeileMeta: { fontSize: schrift.klein, color: farben.textSekundaer, marginTop: 2 },
ampelText: { fontSize: schrift.klein, fontWeight: '600', marginTop: 2 },
leerBox: { alignItems: 'center', gap: abstand.s, marginTop: abstand.xl },
leerTitel: { fontSize: schrift.gross, fontWeight: '700', color: farben.text },
leerText: { fontSize: schrift.basis, color: farben.textSekundaer, textAlign: 'center', lineHeight: 26 },
});

View File

@@ -0,0 +1,325 @@
/**
* Fristen-Übersicht (Start) — Briefe nach Dringlichkeit statt nach Datum.
* Das Wichtigste zuerst: die nächste Frist groß mit Countdown, darunter
* weitere offene Fristen und zuletzt reine Info-Briefe.
*
* Aufbau übernommen aus dem „Postklar"-Design (Claude Design), umgesetzt
* im bestehenden, gut lesbaren iOS-Look von BehördenKlar.
*/
import React from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BriefEintrag, RootStackParamList } from '../types';
import { useAppStore } from '../store/useAppStore';
import { holeConsent } from '../services/storage';
import {
berechneAmpel,
naechsteFrist,
formatiereLangDatum,
NaechsteFrist,
} from '../utils/ampel';
import { Ikone } from '../components/Ikone';
import { farben, schrift, abstand, TOUCH_TARGET } from '../theme';
type Nav = NativeStackNavigationProp<RootStackParamList>;
/** Anzeige-Daten eines Briefs für die Fristen-Übersicht. */
interface FristBrief {
brief: BriefEintrag;
frist: NaechsteFrist | null;
farbe: string;
hintergrund: string;
stufenLabel: string;
}
const STUFEN_LABEL: Record<string, string> = {
rot: 'Dringend',
gelb: 'Handlung nötig',
gruen: 'Zur Kenntnis',
};
function bereiteAuf(brief: BriefEintrag): FristBrief {
const ampel = berechneAmpel(brief.analyse);
return {
brief,
frist: naechsteFrist(brief.analyse),
farbe: ampel.farbe,
hintergrund: ampel.hintergrund,
stufenLabel: STUFEN_LABEL[ampel.stufe],
};
}
/** Runder Kopf-Knopf (Glossar / Einstellungen). */
function KopfKnopf({ ikone, label, onPress }: { ikone: 'buch' | 'zahnrad'; label: string; onPress: () => void }) {
return (
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={label}
style={({ pressed }) => [styles.kopfKnopf, pressed && { opacity: 0.6 }]}
>
<Ikone name={ikone} groesse={20} farbe={farben.primaer} />
</Pressable>
);
}
export function FristenScreen() {
const navigation = useNavigation<Nav>();
const insets = useSafeAreaInsets();
const briefe = useAppStore((s) => s.briefe);
const scanStarten = async () => {
const ok = await holeConsent();
navigation.navigate(ok ? 'Scan' : 'Consent');
};
const aufbereitet = briefe.map(bereiteAuf);
const mitFrist = aufbereitet
.filter((f) => f.frist !== null)
.sort((a, b) => (a.frist!.tage - b.frist!.tage));
const naechste = mitFrist[0];
const weitere = mitFrist.slice(1);
const infoBriefe = aufbereitet.filter((f) => f.frist === null);
const oeffne = (id: string) => navigation.navigate('Analyse', { briefId: id });
return (
<ScrollView
style={styles.container}
contentContainerStyle={{ paddingTop: insets.top + 12, paddingBottom: abstand.xl }}
>
{/* Kopf */}
<View style={styles.kopf}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Ikone name="uhr" groesse={22} farbe={farben.primaer} />
<Text style={styles.marke}>BehördenKlar</Text>
</View>
<View style={{ flexDirection: 'row', gap: 8 }}>
<KopfKnopf ikone="buch" label="Glossar" onPress={() => navigation.navigate('Glossar')} />
<KopfKnopf ikone="zahnrad" label="Einstellungen" onPress={() => navigation.navigate('Einstellungen')} />
</View>
</View>
<View style={{ paddingHorizontal: abstand.m }}>
<Text style={styles.kicker}>Fristen-Übersicht</Text>
<Text style={styles.titel}>Ihre Fristen</Text>
<Text style={styles.unterTitel}>
Nach Dringlichkeit geordnet. Kümmern Sie sich zuerst um das Oberste.
</Text>
{briefe.length === 0 ? (
<View style={styles.leerBox}>
<Ikone name="brief" groesse={44} farbe={farben.textTertiaer} />
<Text style={styles.leerTitel}>Noch keine Briefe</Text>
<Text style={styles.leerText}>
Scannen Sie Ihren ersten Behördenbrief in einer Minute wissen
Sie, was er bedeutet und bis wann Sie handeln müssen.
</Text>
</View>
) : (
<>
{/* Nächste Frist — groß */}
{naechste && (
<Pressable
onPress={() => oeffne(naechste.brief.id)}
accessibilityRole="button"
style={({ pressed }) => [
styles.heldKarte,
{ backgroundColor: naechste.hintergrund, borderLeftColor: naechste.farbe },
pressed && { opacity: 0.85 },
]}
>
<View style={styles.heldKopf}>
<View style={[styles.punkt, { backgroundColor: naechste.farbe }]} />
<Text style={[styles.heldKicker, { color: naechste.farbe }]}>
Nächste Frist · {naechste.stufenLabel}
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'flex-end', gap: 12 }}>
<Text style={[styles.grosseZahl, { color: naechste.farbe }]}>
{naechste.frist!.tage < 0 ? '!' : naechste.frist!.tage}
</Text>
<View style={{ paddingBottom: 8 }}>
<Text style={[styles.zahlEinheit, { color: naechste.farbe }]}>
{naechste.frist!.tage < 0 ? 'überfällig' : 'Tage'}
</Text>
<Text style={styles.zahlDatum}>bis {formatiereLangDatum(naechste.frist!.datumIso)}</Text>
</View>
</View>
<View style={styles.trenner} />
<Text style={styles.briefTyp}>{naechste.brief.analyse.brieftyp}</Text>
<Text style={styles.briefMeta}>
{naechste.brief.analyse.absender}
{naechste.frist!.aktion ? ` · ${naechste.frist!.aktion}` : ''}
</Text>
</Pressable>
)}
{/* Weitere offene Fristen */}
{weitere.length > 0 && (
<>
<Text style={styles.abschnitt}>Weitere offene Fristen</Text>
<View style={{ gap: abstand.s }}>
{weitere.map((f) => (
<Pressable
key={f.brief.id}
onPress={() => oeffne(f.brief.id)}
accessibilityRole="button"
style={({ pressed }) => [
styles.zeile,
{ borderLeftColor: f.farbe },
pressed && { opacity: 0.7 },
]}
>
<View style={styles.zahlBox}>
<Text style={[styles.zahlKlein, { color: f.farbe }]}>
{f.frist!.tage < 0 ? '!' : f.frist!.tage}
</Text>
<Text style={styles.zahlBoxLabel}>Tage</Text>
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.zeileTyp} numberOfLines={1}>{f.brief.analyse.brieftyp}</Text>
<Text style={styles.zeileMeta} numberOfLines={1}>{f.brief.analyse.absender}</Text>
<View style={[styles.tag, { backgroundColor: f.hintergrund }]}>
<Text style={[styles.tagText, { color: f.farbe }]}>{f.stufenLabel}</Text>
</View>
</View>
<Ikone name="pfeilRechts" groesse={16} farbe={farben.textTertiaer} />
</Pressable>
))}
</View>
</>
)}
{/* Ohne Frist */}
{infoBriefe.length > 0 && (
<>
<Text style={styles.abschnitt}>Ohne Frist · nur zur Kenntnis</Text>
<View style={{ gap: abstand.s }}>
{infoBriefe.map((f) => (
<Pressable
key={f.brief.id}
onPress={() => oeffne(f.brief.id)}
accessibilityRole="button"
style={({ pressed }) => [styles.infoZeile, pressed && { opacity: 0.7 }]}
>
<View style={[styles.punkt, { backgroundColor: f.farbe }]} />
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.zeileTyp} numberOfLines={1}>{f.brief.analyse.brieftyp}</Text>
<Text style={styles.zeileMeta} numberOfLines={1}>{f.brief.analyse.absender}</Text>
</View>
<Ikone name="pfeilRechts" groesse={16} farbe={farben.textTertiaer} />
</Pressable>
))}
</View>
</>
)}
</>
)}
{/* Datenschutz-Hinweis */}
<View style={styles.schutz}>
<Ikone name="schloss" groesse={16} farbe={farben.textSekundaer} />
<Text style={styles.schutzText}>
Ihre Briefe sind verschlüsselt und werden nur auf Ihrem Gerät
ausgewertet. Nichts wird ohne Ihre Freigabe geteilt.
</Text>
</View>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: farben.hintergrund },
kopf: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: abstand.m,
marginBottom: abstand.m,
},
marke: { fontSize: schrift.gross, fontWeight: '700', color: farben.text },
kopfKnopf: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: farben.flaeche,
alignItems: 'center',
justifyContent: 'center',
},
kicker: {
fontSize: 12,
letterSpacing: 1.3,
textTransform: 'uppercase',
color: farben.primaer,
fontWeight: '600',
marginBottom: 4,
},
titel: { fontSize: schrift.riesig, fontWeight: '800', color: farben.text, marginBottom: 4 },
unterTitel: { fontSize: schrift.klein, color: farben.textSekundaer, marginBottom: abstand.l },
heldKarte: {
borderRadius: 16,
borderLeftWidth: 5,
padding: abstand.l,
marginBottom: abstand.l,
},
heldKopf: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 12 },
punkt: { width: 9, height: 9, borderRadius: 5 },
heldKicker: { fontSize: 12, letterSpacing: 0.6, textTransform: 'uppercase', fontWeight: '700' },
grosseZahl: { fontSize: 68, lineHeight: 68, fontWeight: '800', fontVariant: ['tabular-nums'] },
zahlEinheit: { fontSize: 22, fontWeight: '700', lineHeight: 24 },
zahlDatum: { fontSize: 13, color: farben.textSekundaer, marginTop: 2 },
trenner: { height: 1, backgroundColor: farben.rand, marginVertical: 15 },
briefTyp: { fontSize: schrift.gross, fontWeight: '700', color: farben.text, marginBottom: 3 },
briefMeta: { fontSize: schrift.klein, color: farben.textSekundaer },
abschnitt: {
fontSize: 12,
letterSpacing: 1.1,
textTransform: 'uppercase',
color: farben.textSekundaer,
fontWeight: '600',
marginTop: abstand.l,
marginBottom: abstand.s,
},
zeile: {
flexDirection: 'row',
alignItems: 'center',
gap: abstand.m,
backgroundColor: farben.flaeche,
borderRadius: 12,
borderLeftWidth: 4,
padding: abstand.m,
},
zahlBox: { width: 54, alignItems: 'center' },
zahlKlein: { fontSize: 30, fontWeight: '800', fontVariant: ['tabular-nums'], lineHeight: 32 },
zahlBoxLabel: { fontSize: 10, letterSpacing: 0.5, textTransform: 'uppercase', color: farben.textTertiaer },
zeileTyp: { fontSize: schrift.basis, fontWeight: '700', color: farben.text },
zeileMeta: { fontSize: schrift.klein, color: farben.textSekundaer, marginTop: 2 },
tag: { alignSelf: 'flex-start', borderRadius: 5, paddingHorizontal: 9, paddingVertical: 3, marginTop: 7 },
tagText: { fontSize: 11, fontWeight: '600' },
infoZeile: {
flexDirection: 'row',
alignItems: 'center',
gap: abstand.s,
backgroundColor: farben.flaeche,
borderRadius: 12,
padding: abstand.m,
minHeight: TOUCH_TARGET,
},
schutz: {
flexDirection: 'row',
gap: abstand.s,
alignItems: 'flex-start',
backgroundColor: farben.flaeche,
borderRadius: 12,
padding: abstand.m,
marginTop: abstand.l,
},
schutzText: { flex: 1, fontSize: 12.5, lineHeight: 19, color: farben.textSekundaer },
leerBox: { alignItems: 'center', gap: abstand.s, marginTop: abstand.xl, marginBottom: abstand.l },
leerTitel: { fontSize: schrift.gross, fontWeight: '700', color: farben.text },
leerText: { fontSize: schrift.basis, color: farben.textSekundaer, textAlign: 'center', lineHeight: 26 },
});

View File

@@ -1,221 +0,0 @@
/**
* 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 { Ikone, IkonenName } from '../components/Ikone';
import { farben, schrift, abstand, TOUCH_TARGET } from '../theme';
type Props = NativeStackScreenProps<RootStackParamList, 'Home'>;
/** Quadratische Navigations-Kachel: Icon oben, Beschriftung darunter.
* (Löst das Platzproblem nebeneinanderstehender Text-Buttons.) */
function Kachel({
ikone,
titel,
onPress,
}: {
ikone: IkonenName;
titel: string;
onPress: () => void;
}) {
return (
<Pressable
style={({ pressed }) => [styles.kachel, pressed && { opacity: 0.65 }]}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={titel}
>
<Ikone name={ikone} groesse={26} farbe={farben.primaer} />
<Text style={styles.kachelTitel} numberOfLines={1}>
{titel}
</Text>
</Pressable>
);
}
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,
{ borderLeftColor: ampel.farbe },
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={{ 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>
<Ikone name="pfeilRechts" groesse={18} farbe={farben.textTertiaer} />
</Pressable>
);
};
return (
<FlatList
style={styles.container}
// iOS: sorgt beim großen Titel für korrekte Abstände + Kollabieren beim Scrollen
contentInsetAdjustmentBehavior="automatic"
keyboardShouldPersistTaps="handled"
data={gefiltert}
keyExtractor={(b) => b.id}
renderItem={renderBrief}
contentContainerStyle={styles.inhalt}
ListHeaderComponent={
<View style={styles.kopf}>
<GrossButton titel="Brief scannen" ikone="kamera" onPress={scanStarten} />
<View style={styles.reihe}>
<Kachel ikone="buch" titel="Glossar" onPress={() => navigation.navigate('Glossar')} />
<Kachel
ikone="zahnrad"
titel="Einstellungen"
onPress={() => navigation.navigate('Einstellungen')}
/>
</View>
{briefe.length > 0 && (
<>
<Text style={styles.abschnittsLabel}>Ihre Briefe</Text>
<TextInput
style={styles.suche}
placeholder="Im Archiv suchen…"
placeholderTextColor={farben.textSekundaer}
value={suche}
onChangeText={setSuche}
accessibilityLabel="Archiv durchsuchen"
/>
</>
)}
</View>
}
ListEmptyComponent={
briefe.length === 0 ? (
<View style={styles.leerBox}>
<Ikone name="brief" groesse={48} farbe={farben.textTertiaer} />
<Text style={styles.leerTitel}>Noch keine Briefe</Text>
<Text style={styles.leer}>
Scannen Sie Ihren ersten Behördenbrief {'\n'}in einer Minute
wissen Sie, was er bedeutet.
</Text>
</View>
) : (
<Text style={styles.leer}>Keine Treffer.</Text>
)
}
/>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: farben.hintergrund },
inhalt: { paddingHorizontal: abstand.m, paddingBottom: abstand.l },
kopf: { paddingTop: abstand.s, paddingBottom: abstand.s, gap: abstand.s },
reihe: { flexDirection: 'row', gap: abstand.s },
kachel: {
flex: 1,
minHeight: TOUCH_TARGET + 22,
backgroundColor: farben.flaeche,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: abstand.s,
gap: 6,
},
kachelTitel: { fontSize: schrift.klein + 1, fontWeight: '600', color: farben.primaer },
abschnittsLabel: {
marginTop: abstand.m,
fontSize: schrift.klein - 1,
fontWeight: '600',
letterSpacing: 1.2,
textTransform: 'uppercase',
color: farben.textSekundaer,
},
suche: {
backgroundColor: farben.flaeche,
borderRadius: 12,
padding: abstand.s,
fontSize: schrift.basis,
color: farben.text,
minHeight: 48,
},
karte: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: farben.flaeche,
borderRadius: 12,
borderLeftWidth: 6,
padding: abstand.m,
marginBottom: abstand.s,
gap: abstand.s,
},
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 },
leerBox: { alignItems: 'center', marginTop: abstand.xl, gap: abstand.s },
leerTitel: { fontSize: schrift.gross, fontWeight: '700', color: farben.text },
leer: {
textAlign: 'center',
fontSize: schrift.basis,
color: farben.textSekundaer,
lineHeight: 28,
},
});

View File

@@ -104,9 +104,16 @@ export const SPRACHEN: Sprache[] = [
{ code: 'pl', name: 'Polnisch', eigenname: 'Polski' },
];
/** Navigation: Parameter aller Screens. */
/** Untere Tab-Leiste: Fristen-Übersicht · Scannen · Archiv. */
export type HauptTabParamList = {
Fristen: undefined;
ScanTab: undefined;
Archiv: undefined;
};
/** Navigation: Root-Stack mit Tab-Leiste + darüber liegenden Detail-Screens. */
export type RootStackParamList = {
Home: undefined;
Tabs: undefined;
Consent: undefined;
Scan: undefined;
Analyse: { briefId: string };

View File

@@ -26,6 +26,55 @@ export function tageBis(datumIso: string): number {
return Math.round((ziel.getTime() - heute.getTime()) / 86400000);
}
/** Die dringendste anstehende Frist/Termin eines Briefs (oder null). */
export interface NaechsteFrist {
/** Ganze Tage bis zum Datum (negativ = vergangen). */
tage: number;
/** ISO-Datum JJJJ-MM-TT. */
datumIso: string;
/** "Frist" oder "Termin". */
label: string;
/** Was bis dahin zu tun ist (bei Fristen), sonst null. */
aktion: string | null;
}
/**
* Liefert das nächste anstehende Datum (Frist ODER Termin) — für die
* Fristen-Übersicht (Countdown, Sortierung). null = kein Datum im Brief.
*/
export function naechsteFrist(analyse: BriefAnalyse): NaechsteFrist | null {
const kandidaten: NaechsteFrist[] = [];
if (analyse.frist) {
kandidaten.push({
tage: tageBis(analyse.frist.datum),
datumIso: analyse.frist.datum,
label: 'Frist',
aktion: analyse.frist.aktion,
});
}
if (analyse.termin) {
kandidaten.push({
tage: tageBis(analyse.termin.datum),
datumIso: analyse.termin.datum,
label: 'Termin',
aktion: null,
});
}
if (kandidaten.length === 0) return null;
return kandidaten.reduce((a, b) => (a.tage <= b.tage ? a : b));
}
/** Datum lesbar formatieren: "27. Juli 2026". */
export function formatiereLangDatum(datumIso: string): string {
const d = new Date(`${datumIso}T00:00:00`);
if (isNaN(d.getTime())) return datumIso;
const monate = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
];
return `${d.getDate()}. ${monate[d.getMonth()]} ${d.getFullYear()}`;
}
export function berechneAmpel(analyse: BriefAnalyse): AmpelStatus {
const daten: { tage: number; label: string }[] = [];
if (analyse.frist) {