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:
108
app/(tabs)/_layout.tsx
Normal file
108
app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Tabs, useRouter } from 'expo-router';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { CameraIcon } from '../../src/components/Icons';
|
||||
import { colors, font, radius, shadow } from '../../src/theme';
|
||||
|
||||
const TABS = [
|
||||
{ name: 'index', label: 'Start' },
|
||||
{ name: 'archive', label: 'Archiv' },
|
||||
] as const;
|
||||
|
||||
// expo-router vendors its own copy of react-navigation, so take the tab bar
|
||||
// props straight off the component rather than deep-importing the type.
|
||||
type TabBarProps = Parameters<NonNullable<ComponentProps<typeof Tabs>['tabBar']>>[0];
|
||||
|
||||
/**
|
||||
* The design's bottom bar: two labels either side of a floating scan button
|
||||
* that overhangs the bar. Active state is a filled bullet above the label.
|
||||
*/
|
||||
function TabBar({ state, navigation }: TabBarProps) {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const activeRoute = state.routes[state.index]?.name;
|
||||
|
||||
return (
|
||||
<View style={[styles.bar, { paddingBottom: Math.max(insets.bottom, 26) }]}>
|
||||
{TABS.map((tab, i) => {
|
||||
const active = activeRoute === tab.name;
|
||||
const item = (
|
||||
<Pressable
|
||||
key={tab.name}
|
||||
style={styles.tab}
|
||||
onPress={() => navigation.navigate(tab.name)}
|
||||
accessibilityRole="tab"
|
||||
accessibilityState={{ selected: active }}
|
||||
>
|
||||
<Text style={[styles.bullet, active ? styles.labelActive : styles.label]}>
|
||||
{active ? '●' : '○'}
|
||||
</Text>
|
||||
<Text style={active ? styles.labelActive : styles.label}>{tab.label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
// Slot the floating scan button between the two tabs.
|
||||
return i === 0
|
||||
? [
|
||||
item,
|
||||
<Pressable
|
||||
key="scan"
|
||||
style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]}
|
||||
onPress={() => router.push('/scan')}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Brief scannen"
|
||||
>
|
||||
<CameraIcon size={26} />
|
||||
</Pressable>,
|
||||
]
|
||||
: item;
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TabsLayout() {
|
||||
return (
|
||||
<Tabs screenOptions={{ headerShown: false }} tabBar={(props) => <TabBar {...props} />}>
|
||||
<Tabs.Screen name="index" options={{ title: 'Start' }} />
|
||||
<Tabs.Screen name="archive" options={{ title: 'Archiv' }} />
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
bar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around',
|
||||
paddingTop: 12,
|
||||
paddingHorizontal: 20,
|
||||
backgroundColor: colors.surface,
|
||||
borderTopLeftRadius: radius['4xl'],
|
||||
borderTopRightRadius: radius['4xl'],
|
||||
...shadow.tabBar,
|
||||
},
|
||||
tab: { alignItems: 'center' },
|
||||
bullet: { fontSize: 11, lineHeight: 14 },
|
||||
label: { fontFamily: font.bold, fontSize: 11, color: colors.slateLight, textAlign: 'center' },
|
||||
labelActive: {
|
||||
fontFamily: font.extrabold,
|
||||
fontSize: 11,
|
||||
color: colors.blue,
|
||||
textAlign: 'center',
|
||||
},
|
||||
fab: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: colors.blue,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: -30,
|
||||
borderWidth: 4,
|
||||
borderColor: colors.canvas,
|
||||
...shadow.fab,
|
||||
},
|
||||
fabPressed: { opacity: 0.85 },
|
||||
});
|
||||
138
app/(tabs)/archive.tsx
Normal file
138
app/(tabs)/archive.tsx
Normal 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 },
|
||||
});
|
||||
152
app/(tabs)/index.tsx
Normal file
152
app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
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 },
|
||||
});
|
||||
Reference in New Issue
Block a user