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

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