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

139 lines
4.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 },
});