Files
Brief-app/app/(tabs)/_layout.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

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 },
});