Files
beh-rde-brief/src/screens/ScanScreen.tsx
JUSN 4ce397026c Apple-natives Design: SF Symbols statt Emojis, iOS-System-Optik
- Neue Ikone-Komponente (expo-symbols): echte SF Symbols auf iOS,
  Material Symbols auf Android — alle Emojis in Buttons/Kacheln/
  Checkboxen/Ampel/Chevrons ersetzt
- Theme: iOS-Systemgrau (#F2F2F7) statt Papier-Ton, weiße Karten ohne
  Rahmen und Schatten (wie Einstellungen/Health), "graue Taste" für
  Sekundär-Buttons statt Umrandung
- Kernaussage-Karte: dezenter Marken-Tint statt Textmarker-Gelb
- Frist/Termin-Zeilen ohne Emoji-Präfixe
- Layout unverändert (Kacheln, Ampel-Streifen, großer Titel)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:32:18 +02:00

236 lines
8.0 KiB
TypeScript

/**
* 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, { useCallback, useState } from 'react';
import { ActivityIndicator, Alert, StyleSheet, Text, View } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import * as ImagePicker from 'expo-image-picker';
import * as DocumentPicker from 'expo-document-picker';
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator';
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, NUTZT_PROXY } from '../services/claudeClient';
import {
GRATIS_ANALYSEN,
holeAnzahlAnalysen,
holeGratisKontingent,
zaehleAnalyse,
} from '../services/storage';
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'>;
/**
* Maximale Kantenlänge fürs KI-Bild. Ein A4-Brief bleibt bei 2000px gut
* lesbar (~170 dpi); moderne Handyfotos (4000px+) würden unverkleinert
* bis zu 3x mehr Bild-Token kosten.
*/
const MAX_BILD_KANTE = 2000;
/**
* Verkleinert ein Foto auf max. MAX_BILD_KANTE (lange Kante, Seitenverhältnis
* bleibt erhalten), komprimiert als JPEG und liefert base64 für die Analyse.
*/
async function bildFuerAnalyse(asset: ImagePicker.ImagePickerAsset): Promise<string> {
const kontext = ImageManipulator.manipulate(asset.uri);
if (Math.max(asset.width, asset.height) > MAX_BILD_KANTE) {
kontext.resize(
asset.width >= asset.height
? { width: MAX_BILD_KANTE }
: { height: MAX_BILD_KANTE }
);
}
const bild = await kontext.renderAsync();
const ergebnis = await bild.saveAsync({
format: SaveFormat.JPEG,
compress: 0.6,
base64: true,
});
return ergebnis.base64!;
}
export function ScanScreen({ navigation }: Props) {
const addBrief = useAppStore((s) => s.addBrief);
const [laedt, setLaedt] = useState(false);
const [verbraucht, setVerbraucht] = useState(0);
const [kontingent, setKontingent] = useState(GRATIS_ANALYSEN);
// Zähler + Kontingent bei jedem Screen-Besuch frisch laden
// (Kontingent kann sich ändern, wenn in den Einstellungen ein
// Aktions-Code eingelöst wurde)
useFocusEffect(
useCallback(() => {
holeAnzahlAnalysen().then(setVerbraucht);
holeGratisKontingent().then(setKontingent);
}, [])
);
// Freemium gilt nur im Produktionsmodus (Proxy). Im Dev-Modus mit
// eigenem API-Key zahlt der Entwickler selbst — kein Limit nötig.
const uebrig = kontingent - verbraucht;
const kontingentLeer = NUTZT_PROXY && uebrig <= 0;
/** Prüft das Gratis-Kontingent; false = Analyse nicht starten. */
const pruefeKontingent = (): boolean => {
if (!kontingentLeer) return true;
Alert.alert(
'Gratis-Analysen aufgebraucht',
`Sie haben Ihre ${kontingent} kostenlosen Brief-Analysen genutzt. Bald können Sie hier ein Abo abschließen, um weiter Briefe zu analysieren.`
);
return false;
};
/** Gemeinsamer Abschluss: analysieren, speichern, Erinnerungen, weiter. */
const verarbeite = async (base64: string, mimeType: string) => {
setLaedt(true);
try {
const analyse = await analysiereBrief(base64, mimeType);
await zaehleAnalyse();
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 () => {
if (!pruefeKontingent()) return;
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',
// Volle Qualität vom Picker — verkleinert & komprimiert wird einmal
// zentral in bildFuerAnalyse (doppelte JPEG-Kompression vermeiden)
quality: 1,
});
if (!ergebnis.canceled && ergebnis.assets[0]) {
await verarbeite(await bildFuerAnalyse(ergebnis.assets[0]), 'image/jpeg');
}
};
const ausGalerie = async () => {
if (!pruefeKontingent()) return;
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: 1,
});
if (!ergebnis.canceled && ergebnis.assets[0]) {
await verarbeite(await bildFuerAnalyse(ergebnis.assets[0]), 'image/jpeg');
}
};
const pdfWaehlen = async () => {
if (!pruefeKontingent()) return;
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" ikone="kamera" onPress={fotoAufnehmen} />
<GrossButton titel="Aus Galerie wählen" ikone="galerie" variante="sekundaer" onPress={ausGalerie} />
<GrossButton titel="PDF hochladen" ikone="dokument" variante="sekundaer" onPress={pdfWaehlen} />
{NUTZT_PROXY && (
<Text style={styles.kontingent}>
{kontingentLeer
? 'Gratis-Analysen aufgebraucht — Abo folgt in Kürze'
: `Noch ${uebrig} von ${kontingent} kostenlosen Analysen`}
</Text>
)}
</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,
},
kontingent: {
fontSize: schrift.klein,
color: farben.textSekundaer,
textAlign: 'center',
marginTop: abstand.m,
},
ladeText: { fontSize: schrift.gross, fontWeight: '700', color: farben.text },
ladeHinweis: {
fontSize: schrift.basis,
color: farben.textSekundaer,
textAlign: 'center',
lineHeight: 26,
},
});