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

138
app/(tabs)/archive.tsx Normal file
View File

@@ -0,0 +1,138 @@
import { useMemo, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { SearchIcon } from '../../src/components/Icons';
import { LetterRow } from '../../src/components/LetterRow';
import { archiveFilters, filterLetters, letters, type ArchiveFilter } from '../../src/data/letters';
import { colors, font, radius, shadow, space } from '../../src/theme';
/** Archive, design option 3b: search, filter chips, letters grouped by month. */
export default function Archive() {
const insets = useSafeAreaInsets();
const [filter, setFilter] = useState<ArchiveFilter>('Alle');
const [query, setQuery] = useState('');
const groups = useMemo(() => {
const needle = query.trim().toLowerCase();
const matches = filterLetters(filter).filter(
(l) =>
!needle ||
l.sender.toLowerCase().includes(needle) ||
l.summary.toLowerCase().includes(needle),
);
const out: { month: string; items: typeof letters }[] = [];
for (const letter of matches) {
const last = out[out.length - 1];
if (last && last.month === letter.month) last.items.push(letter);
else out.push({ month: letter.month, items: [letter] });
}
return out;
}, [filter, query]);
return (
<View style={styles.screen}>
<ScrollView
contentContainerStyle={{ paddingTop: insets.top + 22, paddingBottom: 24 }}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<View style={styles.header}>
<Text style={styles.title}>Dein Archiv</Text>
<Text style={styles.subtitle}>{letters.length} Briefe alles sicher an einem Ort</Text>
</View>
<View style={styles.search}>
<SearchIcon />
<TextInput
style={styles.searchInput}
value={query}
onChangeText={setQuery}
placeholder="Suchen, z. B. „Jobcenter“"
placeholderTextColor={colors.slateLight}
returnKeyType="search"
/>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.chips}
>
{archiveFilters.map((name) => {
const active = name === filter;
return (
<Pressable
key={name}
onPress={() => setFilter(name)}
style={[styles.chip, active ? styles.chipActive : styles.chipIdle]}
accessibilityRole="button"
accessibilityState={{ selected: active }}
>
<Text style={active ? styles.chipLabelActive : styles.chipLabel}>{name}</Text>
</Pressable>
);
})}
</ScrollView>
<View style={styles.body}>
{groups.length === 0 ? (
<Text style={styles.empty}>Keine Briefe gefunden.</Text>
) : (
groups.map((group) => (
<View key={group.month}>
<Text style={styles.month}>{group.month}</Text>
<View style={styles.list}>
{group.items.map((letter) => (
<LetterRow key={letter.id} letter={letter} variant="archive" />
))}
</View>
</View>
))
)}
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: colors.canvas },
header: { paddingHorizontal: space.gutter },
title: { fontFamily: font.display, fontSize: 26, letterSpacing: -0.5, color: colors.ink },
subtitle: { fontFamily: font.semibold, fontSize: 14, color: colors.slate, marginTop: 2 },
search: {
marginHorizontal: space.gutter,
marginTop: 16,
backgroundColor: colors.surface,
borderRadius: radius.lg,
paddingVertical: 13,
paddingHorizontal: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 10,
...shadow.card,
},
searchInput: { flex: 1, fontFamily: font.semibold, fontSize: 14, color: colors.ink, padding: 0 },
chips: { gap: 8, paddingHorizontal: space.gutter, marginTop: 12 },
chip: { borderRadius: radius.pill, paddingVertical: 8, paddingHorizontal: 16 },
chipActive: { backgroundColor: colors.blue },
chipIdle: {
backgroundColor: colors.surface,
shadowColor: '#22304F',
shadowOpacity: 0.06,
shadowRadius: 4,
shadowOffset: { width: 0, height: 1 },
elevation: 1,
},
chipLabel: { fontFamily: font.bold, fontSize: 12.5, color: colors.inkMuted },
chipLabelActive: { fontFamily: font.extrabold, fontSize: 12.5, color: colors.onBlue },
body: { paddingHorizontal: space.gutter, marginTop: 20, gap: 18 },
month: { fontFamily: font.extrabold, fontSize: 12, color: colors.slate, letterSpacing: 0.8 },
list: { gap: 9, marginTop: 10 },
empty: { fontFamily: font.semibold, fontSize: 14, color: colors.slate },
});