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>
243 lines
7.2 KiB
TypeScript
243 lines
7.2 KiB
TypeScript
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,
|
||
},
|
||
});
|