Files
Brief-app/app/scan.tsx
KNSO 66bdf646ae 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>
2026-07-20 14:48:50 +02:00

243 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
},
});