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>
153 lines
5.5 KiB
TypeScript
153 lines
5.5 KiB
TypeScript
import { useRouter } from 'expo-router';
|
||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
import { DitherBackground } from '../../src/components/DitherBackground';
|
||
import { CameraIcon } from '../../src/components/Icons';
|
||
import { LetterRow } from '../../src/components/LetterRow';
|
||
import { letters } from '../../src/data/letters';
|
||
import { colors, font, glow, radius, shadow, space } from '../../src/theme';
|
||
|
||
/** Home, design option 4b: the scan card carries the dither gradient. */
|
||
export default function Home() {
|
||
const router = useRouter();
|
||
const insets = useSafeAreaInsets();
|
||
const openDeadline = letters.find((l) => l.deadlineDays !== undefined);
|
||
|
||
return (
|
||
<View style={styles.screen}>
|
||
<ScrollView
|
||
contentContainerStyle={{ paddingTop: insets.top + 24, paddingBottom: 24 }}
|
||
showsVerticalScrollIndicator={false}
|
||
>
|
||
<View style={styles.header}>
|
||
<Text style={styles.greeting}>Hallo Lena!</Text>
|
||
<Text style={styles.greetingSub}>Ein neuer Brief? Kein Stress – wir helfen dir.</Text>
|
||
</View>
|
||
|
||
<Pressable
|
||
style={({ pressed }) => [styles.scanCard, pressed && styles.pressed]}
|
||
onPress={() => router.push('/scan')}
|
||
accessibilityRole="button"
|
||
accessibilityLabel="Brief scannen"
|
||
>
|
||
<DitherBackground
|
||
color1={colors.blueDeep}
|
||
color2={colors.blueHaze}
|
||
speed={1}
|
||
pixel={2.5}
|
||
style={styles.scanCardBg}
|
||
/>
|
||
<View style={styles.scanCardRow}>
|
||
<View style={styles.scanIcon}>
|
||
<CameraIcon size={28} />
|
||
</View>
|
||
<View style={styles.scanCardText}>
|
||
<Text style={styles.scanTitle}>Brief scannen</Text>
|
||
<Text style={styles.scanSub}>Foto machen, fertig!</Text>
|
||
</View>
|
||
</View>
|
||
<View style={styles.scanCta}>
|
||
<Text style={styles.scanCtaLabel}>Los geht's</Text>
|
||
</View>
|
||
</Pressable>
|
||
|
||
<View style={styles.stats}>
|
||
<View style={styles.stat}>
|
||
<Text style={[styles.statNumber, { color: colors.blueDeep }]}>
|
||
{letters.filter((l) => l.deadlineDays !== undefined).length}
|
||
</Text>
|
||
<Text style={styles.statLabel}>Frist offen</Text>
|
||
<Text style={styles.statHint}>
|
||
{openDeadline ? `noch ${openDeadline.deadlineDays} Tage` : 'alles erledigt'}
|
||
</Text>
|
||
</View>
|
||
<View style={styles.stat}>
|
||
<Text style={[styles.statNumber, { color: colors.blueSoft }]}>{letters.length}</Text>
|
||
<Text style={styles.statLabel}>Briefe erklärt</Text>
|
||
<Text style={styles.statHint}>alles im Archiv</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.listHeader}>
|
||
<Text style={styles.listTitle}>Deine Briefe</Text>
|
||
<Pressable onPress={() => router.push('/(tabs)/archive')} hitSlop={10}>
|
||
<Text style={styles.listAll}>Alle ›</Text>
|
||
</Pressable>
|
||
</View>
|
||
|
||
<View style={styles.list}>
|
||
{letters.slice(0, 2).map((letter) => (
|
||
<LetterRow key={letter.id} letter={letter} />
|
||
))}
|
||
</View>
|
||
</ScrollView>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
screen: { flex: 1, backgroundColor: colors.canvas },
|
||
pressed: { opacity: 0.9 },
|
||
|
||
header: { paddingHorizontal: space.gutter },
|
||
greeting: { fontFamily: font.display, fontSize: 28, letterSpacing: -0.5, color: colors.ink },
|
||
greetingSub: { fontFamily: font.semibold, fontSize: 15, color: colors.slate, marginTop: 3 },
|
||
|
||
scanCard: {
|
||
marginHorizontal: space.gutter,
|
||
marginTop: 20,
|
||
borderRadius: radius['4xl'],
|
||
paddingVertical: 26,
|
||
paddingHorizontal: 24,
|
||
overflow: 'hidden',
|
||
backgroundColor: colors.blueDeep,
|
||
...shadow.blueCard,
|
||
},
|
||
scanCardBg: { borderRadius: radius['4xl'] },
|
||
scanCardRow: { flexDirection: 'row', alignItems: 'center', gap: 14 },
|
||
scanIcon: {
|
||
width: 56,
|
||
height: 56,
|
||
borderRadius: 28,
|
||
backgroundColor: 'rgba(255,255,255,0.22)',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
scanCardText: { flex: 1 },
|
||
scanTitle: { fontFamily: font.display, fontSize: 22, color: colors.onBlue, ...glow },
|
||
scanSub: { fontFamily: font.bold, fontSize: 14, color: colors.onBlue, marginTop: 2, ...glow },
|
||
scanCta: {
|
||
marginTop: 18,
|
||
backgroundColor: '#FFFFFF',
|
||
borderRadius: radius.lg,
|
||
paddingVertical: 14,
|
||
alignItems: 'center',
|
||
},
|
||
scanCtaLabel: { fontFamily: font.extrabold, fontSize: 16, color: colors.blueDeep },
|
||
|
||
stats: { flexDirection: 'row', gap: 10, marginHorizontal: space.gutter, marginTop: 14 },
|
||
stat: {
|
||
flex: 1,
|
||
backgroundColor: colors.surface,
|
||
borderRadius: radius['2xl'],
|
||
paddingVertical: 14,
|
||
paddingHorizontal: 16,
|
||
...shadow.card,
|
||
},
|
||
statNumber: { fontFamily: font.display, fontSize: 22 },
|
||
statLabel: { fontFamily: font.bold, fontSize: 12.5, color: colors.ink, marginTop: 2 },
|
||
statHint: { fontFamily: font.regular, fontSize: 11.5, color: colors.slate },
|
||
|
||
listHeader: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'baseline',
|
||
paddingHorizontal: space.gutter,
|
||
marginTop: 22,
|
||
},
|
||
listTitle: { fontFamily: font.display, fontSize: 17, color: colors.ink },
|
||
listAll: { fontFamily: font.extrabold, fontSize: 13, color: colors.blueDeep },
|
||
|
||
list: { gap: 9, paddingHorizontal: space.gutter, marginTop: 12 },
|
||
});
|