Release-Vorbereitung: Proxy, Kostenoptimierung, Freemium, Store-Assets

- Backend-Proxy (Cloudflare Worker in proxy/): API-Key serverseitig,
  Modell-Whitelist, max_tokens-Deckel, Tageslimits pro Gerät und IP
- Modell-Split: Analyse auf Sonnet 5, Übersetzung/Antwort auf Haiku 4.5
- Bild-Downscaling auf 2000px vor dem Upload (ScanScreen)
- Expo SDK 56 -> 57 Upgrade
- Freemium: 3 Gratis-Analysen mit lokalem Zähler und Paywall-Hinweis
- Einstellungen: API-Key-Feld nur noch im Dev-Modus (NUTZT_PROXY)
- Build-Setup: Bundle-IDs, eas.json, Splash-Screen-Plugin
- Tests: jest-expo + 17 Tests für die Ampel-/Fristlogik
- Rechtliches: Datenschutzerklärung (Entwurf), Webseite (webseite/,
  live auf behoerdenklar.pages.dev), Store-Texte (store/)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 14:14:00 +02:00
parent 88c49e1b54
commit 9dc1d8d87b
26 changed files with 6587 additions and 480 deletions

View File

@@ -2,15 +2,18 @@
* 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 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 } from '../services/claudeClient';
import { ClaudeFehler, NUTZT_PROXY } from '../services/claudeClient';
import { GRATIS_ANALYSEN, holeAnzahlAnalysen, zaehleAnalyse } from '../services/storage';
import { planeErinnerungen } from '../services/erinnerungen';
import { useAppStore, neueId } from '../store/useAppStore';
import { GrossButton } from '../components/GrossButton';
@@ -18,15 +21,68 @@ 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);
// Zähler bei jedem Screen-Besuch frisch laden
useFocusEffect(
useCallback(() => {
holeAnzahlAnalysen().then(setVerbraucht);
}, [])
);
// Freemium gilt nur im Produktionsmodus (Proxy). Im Dev-Modus mit
// eigenem API-Key zahlt der Entwickler selbst — kein Limit nötig.
const uebrig = GRATIS_ANALYSEN - 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 ${GRATIS_ANALYSEN} 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(),
@@ -49,6 +105,7 @@ export function ScanScreen({ navigation }: Props) {
};
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.');
@@ -56,16 +113,17 @@ export function ScanScreen({ navigation }: Props) {
}
const ergebnis = await ImagePicker.launchCameraAsync({
mediaTypes: 'images',
quality: 0.6, // Kompromiss: lesbar für die KI, aber kleine Datenmenge
base64: true,
// Volle Qualität vom Picker — verkleinert & komprimiert wird einmal
// zentral in bildFuerAnalyse (doppelte JPEG-Kompression vermeiden)
quality: 1,
});
if (!ergebnis.canceled && ergebnis.assets[0]?.base64) {
// base64 der ImagePicker-Assets ist laut Doku immer JPEG
await verarbeite(ergebnis.assets[0].base64, 'image/jpeg');
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.');
@@ -73,15 +131,15 @@ export function ScanScreen({ navigation }: Props) {
}
const ergebnis = await ImagePicker.launchImageLibraryAsync({
mediaTypes: 'images',
quality: 0.6,
base64: true,
quality: 1,
});
if (!ergebnis.canceled && ergebnis.assets[0]?.base64) {
await verarbeite(ergebnis.assets[0].base64, 'image/jpeg');
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,
@@ -118,6 +176,13 @@ export function ScanScreen({ navigation }: Props) {
<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} />
{NUTZT_PROXY && (
<Text style={styles.kontingent}>
{kontingentLeer
? 'Gratis-Analysen aufgebraucht — Abo folgt in Kürze'
: `Noch ${uebrig} von ${GRATIS_ANALYSEN} kostenlosen Analysen`}
</Text>
)}
</View>
);
}
@@ -145,6 +210,12 @@ const styles = StyleSheet.create({
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,