Implement Dokio screens from the design doc (turns 3 + 4)

Scaffold an Expo + TypeScript app for Dokio, the app that scans
Behördenpost and explains it in plain language, and build the four
screens specified in "Dokio Design.dc.html":

  4a  Onboarding, full-bleed dither gradient
  4b  Home, dithered scan card
  3a  Kamera, mocked viewfinder
  3b  Archiv, search + filters + month groups

Design tokens from the turn 4 blue palette live in src/theme.ts.
Content is mocked in src/data/letters.ts, taken verbatim from the doc.

DitherBackground is a GLSL port of the doc's dither-bg.js, which drew a
low-resolution canvas and upscaled it with image-rendering: pixelated;
the fragment shader snaps to the same grid instead. Two notes on the
port:

- The original's fract(sin(x) * 43758.5453) hash only distributes well
  in float64. A fragment shader's highp float has ~24 mantissa bits, so
  it collapsed to a near-constant field and the gradient bands rendered
  straight rather than warped. Replaced with Hoskins' hash, verified
  against the original running side by side.
- The 4x4 Bayer matrix is built recursively rather than as a lookup,
  which avoids dynamic array indexing; the construction reproduces all
  16 entries of the original exactly.

The camera screen deliberately stops short of expo-camera: the
viewfinder is a static stand-in, so the flow is navigable without
permissions. The 2a result screen is not part of turns 3 and 4 and is
not included.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 14:48:50 +02:00
commit 66bdf646ae
28 changed files with 9526 additions and 0 deletions

108
app/(tabs)/_layout.tsx Normal file
View File

@@ -0,0 +1,108 @@
import { Tabs, useRouter } from 'expo-router';
import type { ComponentProps } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { CameraIcon } from '../../src/components/Icons';
import { colors, font, radius, shadow } from '../../src/theme';
const TABS = [
{ name: 'index', label: 'Start' },
{ name: 'archive', label: 'Archiv' },
] as const;
// expo-router vendors its own copy of react-navigation, so take the tab bar
// props straight off the component rather than deep-importing the type.
type TabBarProps = Parameters<NonNullable<ComponentProps<typeof Tabs>['tabBar']>>[0];
/**
* The design's bottom bar: two labels either side of a floating scan button
* that overhangs the bar. Active state is a filled bullet above the label.
*/
function TabBar({ state, navigation }: TabBarProps) {
const router = useRouter();
const insets = useSafeAreaInsets();
const activeRoute = state.routes[state.index]?.name;
return (
<View style={[styles.bar, { paddingBottom: Math.max(insets.bottom, 26) }]}>
{TABS.map((tab, i) => {
const active = activeRoute === tab.name;
const item = (
<Pressable
key={tab.name}
style={styles.tab}
onPress={() => navigation.navigate(tab.name)}
accessibilityRole="tab"
accessibilityState={{ selected: active }}
>
<Text style={[styles.bullet, active ? styles.labelActive : styles.label]}>
{active ? '●' : '○'}
</Text>
<Text style={active ? styles.labelActive : styles.label}>{tab.label}</Text>
</Pressable>
);
// Slot the floating scan button between the two tabs.
return i === 0
? [
item,
<Pressable
key="scan"
style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]}
onPress={() => router.push('/scan')}
accessibilityRole="button"
accessibilityLabel="Brief scannen"
>
<CameraIcon size={26} />
</Pressable>,
]
: item;
})}
</View>
);
}
export default function TabsLayout() {
return (
<Tabs screenOptions={{ headerShown: false }} tabBar={(props) => <TabBar {...props} />}>
<Tabs.Screen name="index" options={{ title: 'Start' }} />
<Tabs.Screen name="archive" options={{ title: 'Archiv' }} />
</Tabs>
);
}
const styles = StyleSheet.create({
bar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
paddingTop: 12,
paddingHorizontal: 20,
backgroundColor: colors.surface,
borderTopLeftRadius: radius['4xl'],
borderTopRightRadius: radius['4xl'],
...shadow.tabBar,
},
tab: { alignItems: 'center' },
bullet: { fontSize: 11, lineHeight: 14 },
label: { fontFamily: font.bold, fontSize: 11, color: colors.slateLight, textAlign: 'center' },
labelActive: {
fontFamily: font.extrabold,
fontSize: 11,
color: colors.blue,
textAlign: 'center',
},
fab: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.blue,
alignItems: 'center',
justifyContent: 'center',
marginTop: -30,
borderWidth: 4,
borderColor: colors.canvas,
...shadow.fab,
},
fabPressed: { opacity: 0.85 },
});

138
app/(tabs)/archive.tsx Normal file
View File

@@ -0,0 +1,138 @@
import { useMemo, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { SearchIcon } from '../../src/components/Icons';
import { LetterRow } from '../../src/components/LetterRow';
import { archiveFilters, filterLetters, letters, type ArchiveFilter } from '../../src/data/letters';
import { colors, font, radius, shadow, space } from '../../src/theme';
/** Archive, design option 3b: search, filter chips, letters grouped by month. */
export default function Archive() {
const insets = useSafeAreaInsets();
const [filter, setFilter] = useState<ArchiveFilter>('Alle');
const [query, setQuery] = useState('');
const groups = useMemo(() => {
const needle = query.trim().toLowerCase();
const matches = filterLetters(filter).filter(
(l) =>
!needle ||
l.sender.toLowerCase().includes(needle) ||
l.summary.toLowerCase().includes(needle),
);
const out: { month: string; items: typeof letters }[] = [];
for (const letter of matches) {
const last = out[out.length - 1];
if (last && last.month === letter.month) last.items.push(letter);
else out.push({ month: letter.month, items: [letter] });
}
return out;
}, [filter, query]);
return (
<View style={styles.screen}>
<ScrollView
contentContainerStyle={{ paddingTop: insets.top + 22, paddingBottom: 24 }}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<View style={styles.header}>
<Text style={styles.title}>Dein Archiv</Text>
<Text style={styles.subtitle}>{letters.length} Briefe alles sicher an einem Ort</Text>
</View>
<View style={styles.search}>
<SearchIcon />
<TextInput
style={styles.searchInput}
value={query}
onChangeText={setQuery}
placeholder="Suchen, z. B. „Jobcenter“"
placeholderTextColor={colors.slateLight}
returnKeyType="search"
/>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.chips}
>
{archiveFilters.map((name) => {
const active = name === filter;
return (
<Pressable
key={name}
onPress={() => setFilter(name)}
style={[styles.chip, active ? styles.chipActive : styles.chipIdle]}
accessibilityRole="button"
accessibilityState={{ selected: active }}
>
<Text style={active ? styles.chipLabelActive : styles.chipLabel}>{name}</Text>
</Pressable>
);
})}
</ScrollView>
<View style={styles.body}>
{groups.length === 0 ? (
<Text style={styles.empty}>Keine Briefe gefunden.</Text>
) : (
groups.map((group) => (
<View key={group.month}>
<Text style={styles.month}>{group.month}</Text>
<View style={styles.list}>
{group.items.map((letter) => (
<LetterRow key={letter.id} letter={letter} variant="archive" />
))}
</View>
</View>
))
)}
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: colors.canvas },
header: { paddingHorizontal: space.gutter },
title: { fontFamily: font.display, fontSize: 26, letterSpacing: -0.5, color: colors.ink },
subtitle: { fontFamily: font.semibold, fontSize: 14, color: colors.slate, marginTop: 2 },
search: {
marginHorizontal: space.gutter,
marginTop: 16,
backgroundColor: colors.surface,
borderRadius: radius.lg,
paddingVertical: 13,
paddingHorizontal: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 10,
...shadow.card,
},
searchInput: { flex: 1, fontFamily: font.semibold, fontSize: 14, color: colors.ink, padding: 0 },
chips: { gap: 8, paddingHorizontal: space.gutter, marginTop: 12 },
chip: { borderRadius: radius.pill, paddingVertical: 8, paddingHorizontal: 16 },
chipActive: { backgroundColor: colors.blue },
chipIdle: {
backgroundColor: colors.surface,
shadowColor: '#22304F',
shadowOpacity: 0.06,
shadowRadius: 4,
shadowOffset: { width: 0, height: 1 },
elevation: 1,
},
chipLabel: { fontFamily: font.bold, fontSize: 12.5, color: colors.inkMuted },
chipLabelActive: { fontFamily: font.extrabold, fontSize: 12.5, color: colors.onBlue },
body: { paddingHorizontal: space.gutter, marginTop: 20, gap: 18 },
month: { fontFamily: font.extrabold, fontSize: 12, color: colors.slate, letterSpacing: 0.8 },
list: { gap: 9, marginTop: 10 },
empty: { fontFamily: font.semibold, fontSize: 14, color: colors.slate },
});

152
app/(tabs)/index.tsx Normal file
View File

@@ -0,0 +1,152 @@
import { useRouter } from 'expo-router';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { DitherBackground } from '../../src/components/DitherBackground';
import { CameraIcon } from '../../src/components/Icons';
import { LetterRow } from '../../src/components/LetterRow';
import { letters } from '../../src/data/letters';
import { colors, font, glow, radius, shadow, space } from '../../src/theme';
/** Home, design option 4b: the scan card carries the dither gradient. */
export default function Home() {
const router = useRouter();
const insets = useSafeAreaInsets();
const openDeadline = letters.find((l) => l.deadlineDays !== undefined);
return (
<View style={styles.screen}>
<ScrollView
contentContainerStyle={{ paddingTop: insets.top + 24, paddingBottom: 24 }}
showsVerticalScrollIndicator={false}
>
<View style={styles.header}>
<Text style={styles.greeting}>Hallo Lena!</Text>
<Text style={styles.greetingSub}>Ein neuer Brief? Kein Stress wir helfen dir.</Text>
</View>
<Pressable
style={({ pressed }) => [styles.scanCard, pressed && styles.pressed]}
onPress={() => router.push('/scan')}
accessibilityRole="button"
accessibilityLabel="Brief scannen"
>
<DitherBackground
color1={colors.blueDeep}
color2={colors.blueHaze}
speed={1}
pixel={2.5}
style={styles.scanCardBg}
/>
<View style={styles.scanCardRow}>
<View style={styles.scanIcon}>
<CameraIcon size={28} />
</View>
<View style={styles.scanCardText}>
<Text style={styles.scanTitle}>Brief scannen</Text>
<Text style={styles.scanSub}>Foto machen, fertig!</Text>
</View>
</View>
<View style={styles.scanCta}>
<Text style={styles.scanCtaLabel}>Los geht's</Text>
</View>
</Pressable>
<View style={styles.stats}>
<View style={styles.stat}>
<Text style={[styles.statNumber, { color: colors.blueDeep }]}>
{letters.filter((l) => l.deadlineDays !== undefined).length}
</Text>
<Text style={styles.statLabel}>Frist offen</Text>
<Text style={styles.statHint}>
{openDeadline ? `noch ${openDeadline.deadlineDays} Tage` : 'alles erledigt'}
</Text>
</View>
<View style={styles.stat}>
<Text style={[styles.statNumber, { color: colors.blueSoft }]}>{letters.length}</Text>
<Text style={styles.statLabel}>Briefe erklärt</Text>
<Text style={styles.statHint}>alles im Archiv</Text>
</View>
</View>
<View style={styles.listHeader}>
<Text style={styles.listTitle}>Deine Briefe</Text>
<Pressable onPress={() => router.push('/(tabs)/archive')} hitSlop={10}>
<Text style={styles.listAll}>Alle </Text>
</Pressable>
</View>
<View style={styles.list}>
{letters.slice(0, 2).map((letter) => (
<LetterRow key={letter.id} letter={letter} />
))}
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: colors.canvas },
pressed: { opacity: 0.9 },
header: { paddingHorizontal: space.gutter },
greeting: { fontFamily: font.display, fontSize: 28, letterSpacing: -0.5, color: colors.ink },
greetingSub: { fontFamily: font.semibold, fontSize: 15, color: colors.slate, marginTop: 3 },
scanCard: {
marginHorizontal: space.gutter,
marginTop: 20,
borderRadius: radius['4xl'],
paddingVertical: 26,
paddingHorizontal: 24,
overflow: 'hidden',
backgroundColor: colors.blueDeep,
...shadow.blueCard,
},
scanCardBg: { borderRadius: radius['4xl'] },
scanCardRow: { flexDirection: 'row', alignItems: 'center', gap: 14 },
scanIcon: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: 'rgba(255,255,255,0.22)',
alignItems: 'center',
justifyContent: 'center',
},
scanCardText: { flex: 1 },
scanTitle: { fontFamily: font.display, fontSize: 22, color: colors.onBlue, ...glow },
scanSub: { fontFamily: font.bold, fontSize: 14, color: colors.onBlue, marginTop: 2, ...glow },
scanCta: {
marginTop: 18,
backgroundColor: '#FFFFFF',
borderRadius: radius.lg,
paddingVertical: 14,
alignItems: 'center',
},
scanCtaLabel: { fontFamily: font.extrabold, fontSize: 16, color: colors.blueDeep },
stats: { flexDirection: 'row', gap: 10, marginHorizontal: space.gutter, marginTop: 14 },
stat: {
flex: 1,
backgroundColor: colors.surface,
borderRadius: radius['2xl'],
paddingVertical: 14,
paddingHorizontal: 16,
...shadow.card,
},
statNumber: { fontFamily: font.display, fontSize: 22 },
statLabel: { fontFamily: font.bold, fontSize: 12.5, color: colors.ink, marginTop: 2 },
statHint: { fontFamily: font.regular, fontSize: 11.5, color: colors.slate },
listHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'baseline',
paddingHorizontal: space.gutter,
marginTop: 22,
},
listTitle: { fontFamily: font.display, fontSize: 17, color: colors.ink },
listAll: { fontFamily: font.extrabold, fontSize: 13, color: colors.blueDeep },
list: { gap: 9, paddingHorizontal: space.gutter, marginTop: 12 },
});

42
app/_layout.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { BricolageGrotesque_800ExtraBold } from '@expo-google-fonts/bricolage-grotesque';
import {
NunitoSans_400Regular,
NunitoSans_600SemiBold,
NunitoSans_700Bold,
NunitoSans_800ExtraBold,
} from '@expo-google-fonts/nunito-sans';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { ActivityIndicator, View } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { colors } from '../src/theme';
export default function RootLayout() {
const [fontsLoaded] = useFonts({
BricolageGrotesque_800ExtraBold,
NunitoSans_400Regular,
NunitoSans_600SemiBold,
NunitoSans_700Bold,
NunitoSans_800ExtraBold,
});
if (!fontsLoaded) {
return (
<View style={{ flex: 1, backgroundColor: colors.canvas, justifyContent: 'center' }}>
<ActivityIndicator color={colors.blue} />
</View>
);
}
return (
<SafeAreaProvider>
<StatusBar style="auto" />
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.canvas } }}>
<Stack.Screen name="onboarding" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="scan" options={{ presentation: 'fullScreenModal', animation: 'fade' }} />
</Stack>
</SafeAreaProvider>
);
}

6
app/index.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { Redirect } from 'expo-router';
/** The design opens on onboarding; the tabs live behind it. */
export default function Index() {
return <Redirect href="/onboarding" />;
}

142
app/onboarding.tsx Normal file
View File

@@ -0,0 +1,142 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { DitherBackground } from '../src/components/DitherBackground';
import { colors, font, glow, radius, space } from '../src/theme';
/**
* Onboarding, design option 4a: the dithered gradient runs full-bleed behind
* the whole screen. The design mocks step 2 of 3; the surrounding steps carry
* the same layout.
*/
const STEPS = [
{
headline: 'Behörden-\npost, ohne\nBauchweh',
body: 'Dokio nimmt dir die Angst vor dem Briefkasten.',
},
{
headline: 'Wir übersetzen\nBehördendeutsch',
body: 'Foto machen und du bekommst jeden Brief in einfachen Worten erklärt.',
},
{
headline: 'Keine Frist\nmehr verpassen',
body: 'Wir merken uns jeden Termin und erinnern dich rechtzeitig.',
},
];
export default function Onboarding() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [step, setStep] = useState(1);
const finish = () => router.replace('/(tabs)');
const next = () => (step === STEPS.length - 1 ? finish() : setStep(step + 1));
return (
<View style={styles.screen}>
{/* Finer cells than the design's default of 3, so the dither reads as
texture across the full screen instead of chunky blocks. */}
<DitherBackground color1={colors.blueDeep} color2={colors.blueMist} speed={1} pixel={1.5} />
<View style={[styles.content, { paddingTop: insets.top, paddingBottom: insets.top ? 0 : 12 }]}>
<View style={styles.skipRow}>
<Pressable onPress={finish} hitSlop={12} accessibilityRole="button">
<Text style={styles.skip}>Überspringen</Text>
</Pressable>
</View>
<View style={styles.hero}>
<View style={styles.illustration}>
<Text style={styles.illustrationLabel}>Illustration:{'\n'}Brief Klartext</Text>
</View>
<Text style={styles.headline}>{STEPS[step].headline}</Text>
<Text style={styles.body}>{STEPS[step].body}</Text>
</View>
<View style={styles.dots}>
{STEPS.map((_, i) => (
<View key={i} style={[styles.dot, i === step && styles.dotActive]} />
))}
</View>
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, 22) + 12 }]}>
<Pressable
style={({ pressed }) => [styles.cta, pressed && styles.ctaPressed]}
onPress={next}
accessibilityRole="button"
>
<Text style={styles.ctaLabel}>Weiter</Text>
</Pressable>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: colors.blueDeep },
content: { flex: 1 },
skipRow: { alignItems: 'flex-end', paddingHorizontal: space.gutter, paddingTop: 14 },
skip: { fontFamily: font.bold, fontSize: 13.5, color: colors.onBlue, opacity: 0.8 },
hero: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 36 },
illustration: {
width: 230,
height: 230,
borderRadius: radius['5xl'],
backgroundColor: 'rgba(16,30,90,0.25)',
borderWidth: 1.5,
borderColor: 'rgba(255,255,255,0.45)',
borderStyle: 'dashed',
alignItems: 'center',
justifyContent: 'center',
},
illustrationLabel: {
fontSize: 11,
lineHeight: 16.5,
textAlign: 'center',
color: 'rgba(255,255,255,0.9)',
},
headline: {
fontFamily: font.display,
fontSize: 26,
lineHeight: 32.5,
letterSpacing: -0.3,
textAlign: 'center',
color: colors.onBlue,
marginTop: 34,
textShadowColor: 'rgba(16,30,90,0.3)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 12,
},
body: {
fontFamily: font.bold,
fontSize: 15.5,
lineHeight: 24.8,
textAlign: 'center',
color: colors.onBlue,
marginTop: 12,
...glow,
},
dots: { flexDirection: 'row', justifyContent: 'center', gap: 7, paddingBottom: 22 },
dot: { width: 8, height: 8, borderRadius: 4, backgroundColor: 'rgba(255,255,255,0.4)' },
dotActive: { width: 24, borderRadius: radius.pill, backgroundColor: '#FFFFFF' },
footer: { paddingHorizontal: space.gutter },
cta: {
backgroundColor: '#FFFFFF',
borderRadius: radius.lg,
paddingVertical: 15,
alignItems: 'center',
shadowColor: 'rgba(16,30,90,1)',
shadowOpacity: 0.3,
shadowRadius: 24,
shadowOffset: { width: 0, height: 8 },
elevation: 8,
},
ctaPressed: { opacity: 0.9 },
ctaLabel: { fontFamily: font.extrabold, fontSize: 16, color: colors.blueDeep },
});

242
app/scan.tsx Normal file
View File

@@ -0,0 +1,242 @@
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { FlashIcon, GalleryIcon } from '../src/components/Icons';
import { colors, font, radius } from '../src/theme';
const TOTAL_PAGES = 2;
/**
* Camera / scan, design option 3a. The viewfinder is mocked — a paper stand-in
* under the detection frame — so the flow is navigable without camera
* permissions. Swapping in expo-camera means replacing <Viewfinder/> only.
*/
export default function Scan() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [page, setPage] = useState(1);
const capture = () => {
if (page < TOTAL_PAGES) setPage(page + 1);
else router.back();
};
return (
<View style={[styles.screen, { paddingTop: insets.top }]}>
<View style={styles.header}>
<Pressable
style={styles.headerButton}
onPress={() => router.back()}
accessibilityRole="button"
accessibilityLabel="Scannen abbrechen"
>
<Text style={styles.close}></Text>
</Pressable>
<Text style={styles.headerTitle}>Brief scannen</Text>
<Pressable style={styles.headerButton} accessibilityRole="button" accessibilityLabel="Blitz">
<FlashIcon />
</Pressable>
</View>
<View style={styles.viewfinder}>
<LinearGradient
colors={[colors.viewfinderFrom, colors.viewfinderTo]}
start={{ x: 0.2, y: 0 }}
end={{ x: 0.8, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<View style={styles.paper}>
<View style={[styles.paperLine, styles.paperLineStrong, { width: 80 }]} />
<View style={[styles.paperLine, styles.paperLineStrong, { width: 120, marginTop: 8 }]} />
<View style={styles.paperBody}>
<View style={styles.paperLine} />
<View style={styles.paperLine} />
<View style={[styles.paperLine, { width: '75%' }]} />
<View style={[styles.paperLine, { marginTop: 12 }]} />
<View style={styles.paperLine} />
<View style={[styles.paperLine, { width: '60%' }]} />
<View style={[styles.paperLine, { marginTop: 12 }]} />
<View style={[styles.paperLine, { width: '85%' }]} />
</View>
</View>
<View style={styles.frame} pointerEvents="none">
<View style={[styles.corner, styles.cornerTL]} />
<View style={[styles.corner, styles.cornerTR]} />
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
<View style={styles.status} pointerEvents="none">
<View style={styles.statusDot} />
<Text style={styles.statusText}>Brief erkannt halt still</Text>
</View>
<Text style={styles.tip} pointerEvents="none">
Tipp: Leg den Brief auf einen dunklen Tisch
</Text>
</View>
<View style={[styles.controls, { paddingBottom: Math.max(insets.bottom, 34) }]}>
<Pressable style={styles.controlButton} accessibilityRole="button" accessibilityLabel="Aus Galerie wählen">
<GalleryIcon />
</Pressable>
<Pressable
style={({ pressed }) => [styles.shutter, pressed && styles.shutterPressed]}
onPress={capture}
accessibilityRole="button"
accessibilityLabel={`Seite ${page} von ${TOTAL_PAGES} aufnehmen`}
>
<View style={styles.shutterInner} />
</Pressable>
<View style={styles.controlButton}>
<Text style={styles.pageCount}>
{page}/{TOTAL_PAGES}
</Text>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: colors.night },
header: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 24,
paddingTop: 16,
},
headerButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(255,255,255,0.12)',
alignItems: 'center',
justifyContent: 'center',
},
close: { fontSize: 15, color: colors.onBlue },
headerTitle: {
flex: 1,
textAlign: 'center',
fontFamily: font.display,
fontSize: 16,
color: colors.onBlue,
},
viewfinder: {
flex: 1,
marginHorizontal: 20,
marginVertical: 18,
borderRadius: radius['3xl'],
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
},
paper: {
width: 240,
height: 330,
backgroundColor: colors.paper,
borderRadius: radius.sm,
paddingVertical: 24,
paddingHorizontal: 20,
transform: [{ rotate: '-1.5deg' }],
shadowColor: '#000',
shadowOpacity: 0.4,
shadowRadius: 32,
shadowOffset: { width: 0, height: 12 },
elevation: 12,
},
paperBody: { marginTop: 28, gap: 7 },
paperLine: { height: 6, borderRadius: 3, backgroundColor: colors.paperInk },
paperLineStrong: { height: 8, borderRadius: 4, backgroundColor: colors.paperInkStrong },
frame: { position: 'absolute', width: 272, height: 362 },
corner: { position: 'absolute', width: 34, height: 34, borderColor: colors.blueBright },
cornerTL: { top: 0, left: 0, borderTopWidth: 4, borderLeftWidth: 4, borderTopLeftRadius: 10 },
cornerTR: { top: 0, right: 0, borderTopWidth: 4, borderRightWidth: 4, borderTopRightRadius: 10 },
cornerBL: {
bottom: 0,
left: 0,
borderBottomWidth: 4,
borderLeftWidth: 4,
borderBottomLeftRadius: 10,
},
cornerBR: {
bottom: 0,
right: 0,
borderBottomWidth: 4,
borderRightWidth: 4,
borderBottomRightRadius: 10,
},
status: {
position: 'absolute',
top: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: 'rgba(20,27,46,0.75)',
borderRadius: radius.pill,
paddingVertical: 8,
paddingHorizontal: 16,
},
statusDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: colors.green },
statusText: { fontFamily: font.extrabold, fontSize: 12.5, color: colors.onBlue },
tip: {
position: 'absolute',
bottom: 16,
fontFamily: font.semibold,
fontSize: 12.5,
color: 'rgba(242,246,254,0.75)',
},
controls: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 44,
},
controlButton: {
width: 48,
height: 48,
borderRadius: radius.md,
backgroundColor: 'rgba(255,255,255,0.12)',
alignItems: 'center',
justifyContent: 'center',
},
pageCount: { fontFamily: font.extrabold, fontSize: 11, color: colors.onBlue },
shutter: {
width: 76,
height: 76,
borderRadius: 38,
backgroundColor: colors.blue,
borderWidth: 5,
borderColor: 'rgba(255,255,255,0.25)',
alignItems: 'center',
justifyContent: 'center',
shadowColor: colors.blue,
shadowOpacity: 0.5,
shadowRadius: 20,
shadowOffset: { width: 0, height: 6 },
elevation: 10,
},
shutterPressed: { transform: [{ scale: 0.94 }] },
shutterInner: {
width: 56,
height: 56,
borderRadius: 28,
borderWidth: 3,
borderColor: colors.onBlue,
},
});