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>
109 lines
3.4 KiB
TypeScript
109 lines
3.4 KiB
TypeScript
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 },
|
|
});
|