generated from knso/webklar-preview-template
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:
1 parent
88c49e1b54
commit
9dc1d8d87b
26 files changed
+6587
-480
No files matched your search
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Einstellungen: API-Schlüssel (Dev-Modus), Datenschutz-Info,
|
||||
* "Alle Daten löschen".
|
||||
* Einstellungen: Datenschutz-Info und "Alle Daten löschen".
|
||||
* Das API-Schlüssel-Feld erscheint nur im Entwickler-Modus (ohne Proxy) —
|
||||
* im Produktionsmodus läuft der KI-Zugang über den Backend-Proxy.
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Alert, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
@@ -8,6 +9,7 @@ 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 { NUTZT_PROXY } from '../services/claudeClient';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { GrossButton } from '../components/GrossButton';
|
||||
import { farben, schrift, abstand } from '../theme';
|
||||
@@ -35,7 +37,7 @@ export function EinstellungenScreen({ navigation }: Props) {
|
||||
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.',
|
||||
'Alle gescannten Briefe, Übersetzungen, Entwürfe und die Einwilligung werden dauerhaft gelöscht. Das kann nicht rückgängig gemacht werden.',
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
@@ -57,42 +59,47 @@ export function EinstellungenScreen({ navigation }: Props) {
|
||||
|
||||
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()}
|
||||
/>
|
||||
{!NUTZT_PROXY && (
|
||||
<>
|
||||
<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} />
|
||||
<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'}
|
||||
• Brieffotos werden nur zur Analyse an den KI-Dienst Anthropic (USA)
|
||||
gesendet und dort nach spätestens 30 Tagen gelöscht. Sie werden nicht
|
||||
zum Training der KI verwendet.{'\n\n'}
|
||||
• Alle Ergebnisse liegen ausschließlich auf Ihrem Gerät.{'\n\n'}
|
||||
• Diese App ersetzt keine Rechtsberatung.
|
||||
</Text>
|
||||
|
||||
+82
-11
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user