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

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,
},
});