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:
242
app/scan.tsx
Normal file
242
app/scan.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user