generated from knso/webklar-preview-template
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:
144
App.tsx
144
App.tsx
@@ -1,17 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* BehördenKlar — Behördenbriefe verstehen, übersetzen, beantworten.
|
* BehördenKlar — Behördenbriefe verstehen, übersetzen, beantworten.
|
||||||
* Einstiegspunkt: Navigation + Initialisierung (Archiv, Benachrichtigungen).
|
* Einstiegspunkt: Navigation + Initialisierung (Archiv, Benachrichtigungen).
|
||||||
|
*
|
||||||
|
* Aufbau: untere Tab-Leiste (Fristen · Scannen · Archiv) mit darüber
|
||||||
|
* liegenden Detail-Screens (Analyse, Antwort, Einstellungen …).
|
||||||
*/
|
*/
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
import { Pressable, StyleSheet, View } from 'react-native';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { NavigationContainer } from '@react-navigation/native';
|
import { NavigationContainer, useNavigation } from '@react-navigation/native';
|
||||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
import { createNativeStackNavigator, NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { RootStackParamList } from './src/types';
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { HauptTabParamList, RootStackParamList } from './src/types';
|
||||||
import { useAppStore } from './src/store/useAppStore';
|
import { useAppStore } from './src/store/useAppStore';
|
||||||
import { initialisiereBenachrichtigungen } from './src/services/erinnerungen';
|
import { initialisiereBenachrichtigungen } from './src/services/erinnerungen';
|
||||||
|
import { holeConsent } from './src/services/storage';
|
||||||
import { AppSchutz } from './src/components/AppSchutz';
|
import { AppSchutz } from './src/components/AppSchutz';
|
||||||
|
import { Ikone } from './src/components/Ikone';
|
||||||
import { farben, schrift } from './src/theme';
|
import { farben, schrift } from './src/theme';
|
||||||
import { HomeScreen } from './src/screens/HomeScreen';
|
import { FristenScreen } from './src/screens/FristenScreen';
|
||||||
|
import { ArchivScreen } from './src/screens/ArchivScreen';
|
||||||
import { ConsentScreen } from './src/screens/ConsentScreen';
|
import { ConsentScreen } from './src/screens/ConsentScreen';
|
||||||
import { ScanScreen } from './src/screens/ScanScreen';
|
import { ScanScreen } from './src/screens/ScanScreen';
|
||||||
import { AnalyseScreen } from './src/screens/AnalyseScreen';
|
import { AnalyseScreen } from './src/screens/AnalyseScreen';
|
||||||
@@ -20,6 +29,78 @@ import { GlossarScreen } from './src/screens/GlossarScreen';
|
|||||||
import { EinstellungenScreen } from './src/screens/EinstellungenScreen';
|
import { EinstellungenScreen } from './src/screens/EinstellungenScreen';
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
|
const Tab = createBottomTabNavigator<HauptTabParamList>();
|
||||||
|
|
||||||
|
/** Platzhalter für den mittleren Scan-Tab — seine Taste öffnet den Scan-Flow,
|
||||||
|
* der Inhalt wird nie angezeigt (der Tab-Wechsel wird abgefangen). */
|
||||||
|
const LeererScreen = () => null;
|
||||||
|
|
||||||
|
/** Erhabener runder Scan-Knopf in der Mitte der Tab-Leiste. */
|
||||||
|
function ScanKnopf({ onPress }: { onPress: () => void }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.scanWrap}>
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Brief scannen"
|
||||||
|
style={({ pressed }) => [styles.scanKnopf, pressed && { opacity: 0.85 }]}
|
||||||
|
>
|
||||||
|
<Ikone name="kamera" groesse={26} farbe={farben.primaerText} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HauptTabs() {
|
||||||
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const scanStarten = async () => {
|
||||||
|
const ok = await holeConsent();
|
||||||
|
navigation.navigate(ok ? 'Scan' : 'Consent');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tab.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
tabBarActiveTintColor: farben.primaer,
|
||||||
|
tabBarInactiveTintColor: farben.textTertiaer,
|
||||||
|
tabBarLabelStyle: { fontSize: 11, fontWeight: '600' },
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: farben.flaeche,
|
||||||
|
borderTopColor: farben.rand,
|
||||||
|
height: 56 + insets.bottom,
|
||||||
|
paddingTop: 6,
|
||||||
|
paddingBottom: insets.bottom,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Fristen"
|
||||||
|
component={FristenScreen}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: 'Fristen',
|
||||||
|
tabBarIcon: ({ color }) => <Ikone name="uhr" groesse={23} farbe={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="ScanTab"
|
||||||
|
component={LeererScreen}
|
||||||
|
options={{ tabBarButton: () => <ScanKnopf onPress={scanStarten} /> }}
|
||||||
|
listeners={{ tabPress: (e) => e.preventDefault() }}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Archiv"
|
||||||
|
component={ArchivScreen}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: 'Archiv',
|
||||||
|
tabBarIcon: ({ color }) => <Ikone name="archiv" groesse={23} farbe={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tab.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const initialisiere = useAppStore((s) => s.initialisiere);
|
const initialisiere = useAppStore((s) => s.initialisiere);
|
||||||
@@ -33,26 +114,45 @@ export default function App() {
|
|||||||
<AppSchutz>
|
<AppSchutz>
|
||||||
<NavigationContainer>
|
<NavigationContainer>
|
||||||
<StatusBar style="dark" />
|
<StatusBar style="dark" />
|
||||||
<Stack.Navigator
|
<Stack.Navigator
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerStyle: { backgroundColor: farben.hintergrund },
|
headerStyle: { backgroundColor: farben.hintergrund },
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
// Apple-Konvention: Titel in Textfarbe, nur Interaktives im Blau-Tint
|
headerTintColor: farben.primaer,
|
||||||
headerTintColor: farben.primaer,
|
headerTitleStyle: { fontSize: schrift.gross, fontWeight: '700', color: farben.text },
|
||||||
headerTitleStyle: { fontSize: schrift.gross, fontWeight: '700', color: farben.text },
|
headerBackTitle: 'Zurück',
|
||||||
headerBackTitle: 'Zurück',
|
contentStyle: { backgroundColor: farben.hintergrund },
|
||||||
contentStyle: { backgroundColor: farben.hintergrund },
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Stack.Screen name="Tabs" component={HauptTabs} options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="Home" component={HomeScreen} options={{ title: 'BehördenKlar' }} />
|
<Stack.Screen name="Consent" component={ConsentScreen} options={{ title: 'Datenschutz' }} />
|
||||||
<Stack.Screen name="Consent" component={ConsentScreen} options={{ title: 'Datenschutz' }} />
|
<Stack.Screen name="Scan" component={ScanScreen} options={{ title: 'Brief scannen' }} />
|
||||||
<Stack.Screen name="Scan" component={ScanScreen} options={{ title: 'Brief scannen' }} />
|
<Stack.Screen name="Analyse" component={AnalyseScreen} options={{ title: 'Ihr Brief erklärt' }} />
|
||||||
<Stack.Screen name="Analyse" component={AnalyseScreen} options={{ title: 'Ihr Brief erklärt' }} />
|
<Stack.Screen name="Antwort" component={AntwortScreen} options={{ title: 'Antwort erstellen' }} />
|
||||||
<Stack.Screen name="Antwort" component={AntwortScreen} options={{ title: 'Antwort erstellen' }} />
|
<Stack.Screen name="Glossar" component={GlossarScreen} options={{ title: 'Behörden-Glossar' }} />
|
||||||
<Stack.Screen name="Glossar" component={GlossarScreen} options={{ title: 'Behörden-Glossar' }} />
|
<Stack.Screen name="Einstellungen" component={EinstellungenScreen} options={{ title: 'Einstellungen' }} />
|
||||||
<Stack.Screen name="Einstellungen" component={EinstellungenScreen} options={{ title: 'Einstellungen' }} />
|
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
</AppSchutz>
|
</AppSchutz>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
scanWrap: { flex: 1, alignItems: 'center', justifyContent: 'flex-start' },
|
||||||
|
scanKnopf: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
marginTop: -18,
|
||||||
|
backgroundColor: farben.primaer,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: farben.flaeche,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 3 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 5,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
70
package-lock.json
generated
70
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.18.14",
|
||||||
"@react-navigation/native": "^7.3.8",
|
"@react-navigation/native": "^7.3.8",
|
||||||
"@react-navigation/native-stack": "^7.17.10",
|
"@react-navigation/native-stack": "^7.17.10",
|
||||||
"expo": "^57.0.0",
|
"expo": "^57.0.0",
|
||||||
@@ -2344,13 +2345,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-navigation/core": {
|
"node_modules/@react-navigation/bottom-tabs": {
|
||||||
"version": "7.21.5",
|
"version": "7.18.14",
|
||||||
"resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.18.14.tgz",
|
||||||
"integrity": "sha512-3hpV7uR41LBW+GHDoLhztZCb/i5ySRJISZ/rez4d7DCHSZo6ej4gNxYclaS6LRguoLiKG7SOCNa6O390AQklZQ==",
|
"integrity": "sha512-A3V9rDSut459TBPtkD7rb0npUUBlJBfMunyRT5nOGKqPguhuXWk1h91NfQMuqyW2DCUvvFZChzsbLbj42rXTdQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-navigation/routers": "^7.6.0",
|
"@react-navigation/elements": "^2.9.36",
|
||||||
|
"color": "^4.2.3",
|
||||||
|
"sf-symbols-typescript": "^2.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@react-navigation/native": "^7.3.14",
|
||||||
|
"react": ">= 18.2.0",
|
||||||
|
"react-native": "*",
|
||||||
|
"react-native-safe-area-context": ">= 4.0.0",
|
||||||
|
"react-native-screens": ">= 4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-navigation/core": {
|
||||||
|
"version": "7.21.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.21.11.tgz",
|
||||||
|
"integrity": "sha512-bCW1PsLA/eOXDOukcJFEzlcL3Zpy8DJuDCfkDDwAQlAgoSZ/J9+ZeDRUMmCUi6xbnFgvFEEIMertaLeErOFP0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-navigation/routers": "^7.6.4",
|
||||||
"escape-string-regexp": "^4.0.0",
|
"escape-string-regexp": "^4.0.0",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
@@ -2364,15 +2383,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-navigation/core/node_modules/react-is": {
|
"node_modules/@react-navigation/core/node_modules/react-is": {
|
||||||
"version": "19.2.7",
|
"version": "19.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz",
|
||||||
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
|
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@react-navigation/elements": {
|
"node_modules/@react-navigation/elements": {
|
||||||
"version": "2.9.30",
|
"version": "2.9.36",
|
||||||
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.30.tgz",
|
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.36.tgz",
|
||||||
"integrity": "sha512-2isleieiRMmP4WNMV2Q1u3qP1M47ZqsJ2hJ/Og11FeKXK8YmUTHya7PW7ecsgAh2CXKTxAcbfJDFTSvq2D++Tw==",
|
"integrity": "sha512-+10x9s5v2Q7FwAYdSmPMgILtxZyC5e4hWJQu8g5o3u4p8DUToTBmGvys/UvmEr+h9xmm0Go42qw9Ff2ape53kQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color": "^4.2.3",
|
"color": "^4.2.3",
|
||||||
@@ -2381,7 +2400,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@react-native-masked-view/masked-view": ">= 0.2.0",
|
"@react-native-masked-view/masked-view": ">= 0.2.0",
|
||||||
"@react-navigation/native": "^7.3.8",
|
"@react-navigation/native": "^7.3.14",
|
||||||
"react": ">= 18.2.0",
|
"react": ">= 18.2.0",
|
||||||
"react-native": "*",
|
"react-native": "*",
|
||||||
"react-native-safe-area-context": ">= 4.0.0"
|
"react-native-safe-area-context": ">= 4.0.0"
|
||||||
@@ -2393,16 +2412,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-navigation/native": {
|
"node_modules/@react-navigation/native": {
|
||||||
"version": "7.3.8",
|
"version": "7.3.14",
|
||||||
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.3.14.tgz",
|
||||||
"integrity": "sha512-zHmQcxWBT8GOwsofEOmHqpdM5twkwE/esa9JFGlW4hpXeQTTe/dRcPSLjwsvePtolbDKz0YZbK+I5KrW3j63LQ==",
|
"integrity": "sha512-hcKTDNBuuAA1/xW6QeKYmMPVhk5W9dKGQpPmn5dQeeePwMpu5OZ14NOgwKH0w9D3tg2jupojTcVL0tsx5DTFXg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-navigation/core": "^7.21.5",
|
"@react-navigation/core": "^7.21.11",
|
||||||
"escape-string-regexp": "^4.0.0",
|
"escape-string-regexp": "^4.0.0",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"standard-navigation": "^0.0.7",
|
"standard-navigation": "^0.0.8",
|
||||||
"use-latest-callback": "^0.2.4"
|
"use-latest-callback": "^0.2.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -2430,9 +2449,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-navigation/routers": {
|
"node_modules/@react-navigation/routers": {
|
||||||
"version": "7.6.0",
|
"version": "7.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.6.4.tgz",
|
||||||
"integrity": "sha512-lblhDXfS75jLc7G2K7BZGM+7cjqQXk13X/MA4fq/12r62zM+fBhhreLzYflSitrDDXFRJpSvJXy0ziiGU04Xow==",
|
"integrity": "sha512-GI7eJm8/KsZUQaYcXvEExikKurRZRgEsSzyZ7faENfi65yqJBCXjDMwyN1pF6pNW1MoLH1ErDwDivFxY6BzD3w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11"
|
"nanoid": "^3.3.11"
|
||||||
@@ -9108,10 +9127,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/standard-navigation": {
|
"node_modules/standard-navigation": {
|
||||||
"version": "0.0.7",
|
"version": "0.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/standard-navigation/-/standard-navigation-0.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/standard-navigation/-/standard-navigation-0.0.8.tgz",
|
||||||
"integrity": "sha512-NCGLCNyuXrFOkGHxdNZFnpsehGtiq1oXbPhKl7ZuxFO5J//H2evqqOchmD4YwEUJnkjO4kH9Xp4hQX6hdAYCKQ==",
|
"integrity": "sha512-TyVbo7INUDWtsUWDFn8RR7kwR87U0S4xHfLfbbnyeC581TmmyqQ+eM+nPw8rQTSD8QitRVcYfPaSHr/QJiUy1g==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.18.14",
|
||||||
"@react-navigation/native": "^7.3.8",
|
"@react-navigation/native": "^7.3.8",
|
||||||
"@react-navigation/native-stack": "^7.17.10",
|
"@react-navigation/native-stack": "^7.17.10",
|
||||||
"expo": "^57.0.0",
|
"expo": "^57.0.0",
|
||||||
|
|||||||
@@ -27,7 +27,12 @@ export type IkonenName =
|
|||||||
| 'warnung'
|
| 'warnung'
|
||||||
| 'wecker'
|
| 'wecker'
|
||||||
| 'info'
|
| 'info'
|
||||||
| 'funken';
|
| 'funken'
|
||||||
|
| 'uhr'
|
||||||
|
| 'archiv'
|
||||||
|
| 'schloss'
|
||||||
|
| 'lupe'
|
||||||
|
| 'pfeilLinks';
|
||||||
|
|
||||||
// `as const` hält die Namen als Literal-Typen — so prüft TypeScript gegen
|
// `as const` hält die Namen als Literal-Typen — so prüft TypeScript gegen
|
||||||
// die offizielle SF-Symbol-/Material-Namensliste von expo-symbols
|
// die offizielle SF-Symbol-/Material-Namensliste von expo-symbols
|
||||||
@@ -52,6 +57,11 @@ const NAMEN = {
|
|||||||
wecker: { ios: 'alarm.fill', android: 'alarm' },
|
wecker: { ios: 'alarm.fill', android: 'alarm' },
|
||||||
info: { ios: 'info.circle.fill', android: 'info' },
|
info: { ios: 'info.circle.fill', android: 'info' },
|
||||||
funken: { ios: 'wand.and.stars', android: 'auto_awesome' },
|
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;
|
} as const;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
188
src/screens/ArchivScreen.tsx
Normal file
188
src/screens/ArchivScreen.tsx
Normal 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 },
|
||||||
|
});
|
||||||
325
src/screens/FristenScreen.tsx
Normal file
325
src/screens/FristenScreen.tsx
Normal 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 },
|
||||||
|
});
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
11
src/types.ts
11
src/types.ts
@@ -104,9 +104,16 @@ export const SPRACHEN: Sprache[] = [
|
|||||||
{ code: 'pl', name: 'Polnisch', eigenname: 'Polski' },
|
{ 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 = {
|
export type RootStackParamList = {
|
||||||
Home: undefined;
|
Tabs: undefined;
|
||||||
Consent: undefined;
|
Consent: undefined;
|
||||||
Scan: undefined;
|
Scan: undefined;
|
||||||
Analyse: { briefId: string };
|
Analyse: { briefId: string };
|
||||||
|
|||||||
@@ -26,6 +26,55 @@ export function tageBis(datumIso: string): number {
|
|||||||
return Math.round((ziel.getTime() - heute.getTime()) / 86400000);
|
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 {
|
export function berechneAmpel(analyse: BriefAnalyse): AmpelStatus {
|
||||||
const daten: { tage: number; label: string }[] = [];
|
const daten: { tage: number; label: string }[] = [];
|
||||||
if (analyse.frist) {
|
if (analyse.frist) {
|
||||||
|
|||||||
Reference in New Issue
Block a user