Merge Dokio design implementation (turns 3 + 4)

Brings the Expo app onto main: the four screens from the design doc,
the design tokens, and the GLSL port of the dither background.

The branch was rooted while main was still unborn, so the two histories
are unrelated and the merge needs --allow-unrelated-histories. No files
overlap; main carried only .webklar-preview.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 15:29:59 +02:00
28 changed files with 9538 additions and 0 deletions

11
.claude/launch.json Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dokio-web",
"runtimeExecutable": "npx",
"runtimeArgs": ["expo", "start", "--web", "--port", "8081"],
"port": 8081
}
]
}

5
.claude/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

3
AGENTS.md Normal file
View File

@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

32
app.json Normal file
View File

@@ -0,0 +1,32 @@
{
"expo": {
"name": "Dokio",
"slug": "dokio",
"scheme": "dokio",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#EEF3FC",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false
},
"web": {
"bundler": "metro",
"output": "single",
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
"expo-font"
]
}
}

108
app/(tabs)/_layout.tsx Normal file
View 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
View 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
View 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.serif, 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 },
});

44
app/_layout.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { BricolageGrotesque_800ExtraBold } from '@expo-google-fonts/bricolage-grotesque';
import { Fraunces_800ExtraBold } from '@expo-google-fonts/fraunces';
import {
NunitoSans_400Regular,
NunitoSans_600SemiBold,
NunitoSans_700Bold,
NunitoSans_800ExtraBold,
} from '@expo-google-fonts/nunito-sans';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { ActivityIndicator, View } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { colors } from '../src/theme';
export default function RootLayout() {
const [fontsLoaded] = useFonts({
BricolageGrotesque_800ExtraBold,
Fraunces_800ExtraBold,
NunitoSans_400Regular,
NunitoSans_600SemiBold,
NunitoSans_700Bold,
NunitoSans_800ExtraBold,
});
if (!fontsLoaded) {
return (
<View style={{ flex: 1, backgroundColor: colors.canvas, justifyContent: 'center' }}>
<ActivityIndicator color={colors.blue} />
</View>
);
}
return (
<SafeAreaProvider>
<StatusBar style="auto" />
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.canvas } }}>
<Stack.Screen name="onboarding" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="scan" options={{ presentation: 'fullScreenModal', animation: 'fade' }} />
</Stack>
</SafeAreaProvider>
);
}

6
app/index.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { Redirect } from 'expo-router';
/** The design opens on onboarding; the tabs live behind it. */
export default function Index() {
return <Redirect href="/onboarding" />;
}

142
app/onboarding.tsx Normal file
View File

@@ -0,0 +1,142 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { DitherBackground } from '../src/components/DitherBackground';
import { colors, font, glow, radius, space } from '../src/theme';
/**
* Onboarding, design option 4a: the dithered gradient runs full-bleed behind
* the whole screen. The design mocks step 2 of 3; the surrounding steps carry
* the same layout.
*/
const STEPS = [
{
headline: 'Behörden-\npost, ohne\nBauchweh',
body: 'Dokio nimmt dir die Angst vor dem Briefkasten.',
},
{
headline: 'Wir übersetzen\nBehördendeutsch',
body: 'Foto machen und du bekommst jeden Brief in einfachen Worten erklärt.',
},
{
headline: 'Keine Frist\nmehr verpassen',
body: 'Wir merken uns jeden Termin und erinnern dich rechtzeitig.',
},
];
export default function Onboarding() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [step, setStep] = useState(1);
const finish = () => router.replace('/(tabs)');
const next = () => (step === STEPS.length - 1 ? finish() : setStep(step + 1));
return (
<View style={styles.screen}>
{/* Finer cells than the design's default of 3, so the dither reads as
texture across the full screen instead of chunky blocks. */}
<DitherBackground color1={colors.blueDeep} color2={colors.blueMist} speed={1} pixel={1.5} />
<View style={[styles.content, { paddingTop: insets.top, paddingBottom: insets.top ? 0 : 12 }]}>
<View style={styles.skipRow}>
<Pressable onPress={finish} hitSlop={12} accessibilityRole="button">
<Text style={styles.skip}>Überspringen</Text>
</Pressable>
</View>
<View style={styles.hero}>
<View style={styles.illustration}>
<Text style={styles.illustrationLabel}>Illustration:{'\n'}Brief Klartext</Text>
</View>
<Text style={styles.headline}>{STEPS[step].headline}</Text>
<Text style={styles.body}>{STEPS[step].body}</Text>
</View>
<View style={styles.dots}>
{STEPS.map((_, i) => (
<View key={i} style={[styles.dot, i === step && styles.dotActive]} />
))}
</View>
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, 22) + 12 }]}>
<Pressable
style={({ pressed }) => [styles.cta, pressed && styles.ctaPressed]}
onPress={next}
accessibilityRole="button"
>
<Text style={styles.ctaLabel}>Weiter</Text>
</Pressable>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: colors.blueDeep },
content: { flex: 1 },
skipRow: { alignItems: 'flex-end', paddingHorizontal: space.gutter, paddingTop: 14 },
skip: { fontFamily: font.bold, fontSize: 13.5, color: colors.onBlue, opacity: 0.8 },
hero: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 36 },
illustration: {
width: 230,
height: 230,
borderRadius: radius['5xl'],
backgroundColor: 'rgba(16,30,90,0.25)',
borderWidth: 1.5,
borderColor: 'rgba(255,255,255,0.45)',
borderStyle: 'dashed',
alignItems: 'center',
justifyContent: 'center',
},
illustrationLabel: {
fontSize: 11,
lineHeight: 16.5,
textAlign: 'center',
color: 'rgba(255,255,255,0.9)',
},
headline: {
fontFamily: font.display,
fontSize: 26,
lineHeight: 32.5,
letterSpacing: -0.3,
textAlign: 'center',
color: colors.onBlue,
marginTop: 34,
textShadowColor: 'rgba(16,30,90,0.3)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 12,
},
body: {
fontFamily: font.bold,
fontSize: 15.5,
lineHeight: 24.8,
textAlign: 'center',
color: colors.onBlue,
marginTop: 12,
...glow,
},
dots: { flexDirection: 'row', justifyContent: 'center', gap: 7, paddingBottom: 22 },
dot: { width: 8, height: 8, borderRadius: 4, backgroundColor: 'rgba(255,255,255,0.4)' },
dotActive: { width: 24, borderRadius: radius.pill, backgroundColor: '#FFFFFF' },
footer: { paddingHorizontal: space.gutter },
cta: {
backgroundColor: '#FFFFFF',
borderRadius: radius.lg,
paddingVertical: 15,
alignItems: 'center',
shadowColor: 'rgba(16,30,90,1)',
shadowOpacity: 0.3,
shadowRadius: 24,
shadowOffset: { width: 0, height: 8 },
elevation: 8,
},
ctaPressed: { opacity: 0.9 },
ctaLabel: { fontFamily: font.extrabold, fontSize: 16, color: colors.blueDeep },
});

242
app/scan.tsx Normal file
View File

@@ -0,0 +1,242 @@
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { FlashIcon, GalleryIcon } from '../src/components/Icons';
import { colors, font, radius } from '../src/theme';
const TOTAL_PAGES = 2;
/**
* Camera / scan, design option 3a. The viewfinder is mocked — a paper stand-in
* under the detection frame — so the flow is navigable without camera
* permissions. Swapping in expo-camera means replacing <Viewfinder/> only.
*/
export default function Scan() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [page, setPage] = useState(1);
const capture = () => {
if (page < TOTAL_PAGES) setPage(page + 1);
else router.back();
};
return (
<View style={[styles.screen, { paddingTop: insets.top }]}>
<View style={styles.header}>
<Pressable
style={styles.headerButton}
onPress={() => router.back()}
accessibilityRole="button"
accessibilityLabel="Scannen abbrechen"
>
<Text style={styles.close}></Text>
</Pressable>
<Text style={styles.headerTitle}>Brief scannen</Text>
<Pressable style={styles.headerButton} accessibilityRole="button" accessibilityLabel="Blitz">
<FlashIcon />
</Pressable>
</View>
<View style={styles.viewfinder}>
<LinearGradient
colors={[colors.viewfinderFrom, colors.viewfinderTo]}
start={{ x: 0.2, y: 0 }}
end={{ x: 0.8, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<View style={styles.paper}>
<View style={[styles.paperLine, styles.paperLineStrong, { width: 80 }]} />
<View style={[styles.paperLine, styles.paperLineStrong, { width: 120, marginTop: 8 }]} />
<View style={styles.paperBody}>
<View style={styles.paperLine} />
<View style={styles.paperLine} />
<View style={[styles.paperLine, { width: '75%' }]} />
<View style={[styles.paperLine, { marginTop: 12 }]} />
<View style={styles.paperLine} />
<View style={[styles.paperLine, { width: '60%' }]} />
<View style={[styles.paperLine, { marginTop: 12 }]} />
<View style={[styles.paperLine, { width: '85%' }]} />
</View>
</View>
<View style={styles.frame} pointerEvents="none">
<View style={[styles.corner, styles.cornerTL]} />
<View style={[styles.corner, styles.cornerTR]} />
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
<View style={styles.status} pointerEvents="none">
<View style={styles.statusDot} />
<Text style={styles.statusText}>Brief erkannt halt still</Text>
</View>
<Text style={styles.tip} pointerEvents="none">
Tipp: Leg den Brief auf einen dunklen Tisch
</Text>
</View>
<View style={[styles.controls, { paddingBottom: Math.max(insets.bottom, 34) }]}>
<Pressable style={styles.controlButton} accessibilityRole="button" accessibilityLabel="Aus Galerie wählen">
<GalleryIcon />
</Pressable>
<Pressable
style={({ pressed }) => [styles.shutter, pressed && styles.shutterPressed]}
onPress={capture}
accessibilityRole="button"
accessibilityLabel={`Seite ${page} von ${TOTAL_PAGES} aufnehmen`}
>
<View style={styles.shutterInner} />
</Pressable>
<View style={styles.controlButton}>
<Text style={styles.pageCount}>
{page}/{TOTAL_PAGES}
</Text>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: colors.night },
header: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 24,
paddingTop: 16,
},
headerButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(255,255,255,0.12)',
alignItems: 'center',
justifyContent: 'center',
},
close: { fontSize: 15, color: colors.onBlue },
headerTitle: {
flex: 1,
textAlign: 'center',
fontFamily: font.display,
fontSize: 16,
color: colors.onBlue,
},
viewfinder: {
flex: 1,
marginHorizontal: 20,
marginVertical: 18,
borderRadius: radius['3xl'],
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
},
paper: {
width: 240,
height: 330,
backgroundColor: colors.paper,
borderRadius: radius.sm,
paddingVertical: 24,
paddingHorizontal: 20,
transform: [{ rotate: '-1.5deg' }],
shadowColor: '#000',
shadowOpacity: 0.4,
shadowRadius: 32,
shadowOffset: { width: 0, height: 12 },
elevation: 12,
},
paperBody: { marginTop: 28, gap: 7 },
paperLine: { height: 6, borderRadius: 3, backgroundColor: colors.paperInk },
paperLineStrong: { height: 8, borderRadius: 4, backgroundColor: colors.paperInkStrong },
frame: { position: 'absolute', width: 272, height: 362 },
corner: { position: 'absolute', width: 34, height: 34, borderColor: colors.blueBright },
cornerTL: { top: 0, left: 0, borderTopWidth: 4, borderLeftWidth: 4, borderTopLeftRadius: 10 },
cornerTR: { top: 0, right: 0, borderTopWidth: 4, borderRightWidth: 4, borderTopRightRadius: 10 },
cornerBL: {
bottom: 0,
left: 0,
borderBottomWidth: 4,
borderLeftWidth: 4,
borderBottomLeftRadius: 10,
},
cornerBR: {
bottom: 0,
right: 0,
borderBottomWidth: 4,
borderRightWidth: 4,
borderBottomRightRadius: 10,
},
status: {
position: 'absolute',
top: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: 'rgba(20,27,46,0.75)',
borderRadius: radius.pill,
paddingVertical: 8,
paddingHorizontal: 16,
},
statusDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: colors.green },
statusText: { fontFamily: font.extrabold, fontSize: 12.5, color: colors.onBlue },
tip: {
position: 'absolute',
bottom: 16,
fontFamily: font.semibold,
fontSize: 12.5,
color: 'rgba(242,246,254,0.75)',
},
controls: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 44,
},
controlButton: {
width: 48,
height: 48,
borderRadius: radius.md,
backgroundColor: 'rgba(255,255,255,0.12)',
alignItems: 'center',
justifyContent: 'center',
},
pageCount: { fontFamily: font.extrabold, fontSize: 11, color: colors.onBlue },
shutter: {
width: 76,
height: 76,
borderRadius: 38,
backgroundColor: colors.blue,
borderWidth: 5,
borderColor: 'rgba(255,255,255,0.25)',
alignItems: 'center',
justifyContent: 'center',
shadowColor: colors.blue,
shadowOpacity: 0.5,
shadowRadius: 20,
shadowOffset: { width: 0, height: 6 },
elevation: 10,
},
shutterPressed: { transform: [{ scale: 0.94 }] },
shutterInner: {
width: 56,
height: 56,
borderRadius: 28,
borderWidth: 3,
borderColor: colors.onBlue,
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

BIN
assets/splash-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

7874
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "dokio",
"version": "1.0.0",
"main": "expo-router/entry",
"dependencies": {
"@expo-google-fonts/bricolage-grotesque": "^0.4.1",
"@expo-google-fonts/fraunces": "^0.4.1",
"@expo-google-fonts/nunito-sans": "^0.4.2",
"expo": "~57.0.7",
"expo-constants": "~57.0.6",
"expo-font": "~57.0.1",
"expo-gl": "~57.0.2",
"expo-linear-gradient": "~57.0.1",
"expo-linking": "~57.0.3",
"expo-router": "~57.0.7",
"expo-status-bar": "~57.0.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.0",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"react-native-web": "^0.21.2"
},
"devDependencies": {
"@types/react": "~19.2.2",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"typecheck": "tsc --noEmit"
},
"private": true
}

View File

@@ -0,0 +1,237 @@
import { GLView } from 'expo-gl';
import type { ExpoWebGLRenderingContext } from 'expo-gl';
import { useCallback, useEffect, useRef } from 'react';
import { PixelRatio, StyleSheet, View, type ViewProps } from 'react-native';
/**
* GPU port of `dither-bg.js` from the design doc: a diagonal gradient broken
* into four palette steps, ordered-dithered with a 4x4 Bayer matrix, drifting
* slowly under value noise, and faded to white in the bottom-left corner.
*
* The original rendered a low-resolution canvas and upscaled it with
* `image-rendering: pixelated`. Here the fragment shader snaps to the same
* low-resolution grid, so the chunky pixels survive without a second surface.
*/
const VERT = `
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
`;
const FRAG = `
precision highp float;
uniform vec2 uResolution;
uniform float uTime;
uniform float uPixel;
uniform vec3 uPal0;
uniform vec3 uPal1;
uniform vec3 uPal2;
uniform vec3 uPal3;
// The original used fract(sin(...) * 43758.5453), which only distributes well
// in float64. A fragment shader's highp float has ~24 bits of mantissa, so that
// hash collapses to a near-constant field and the gradient bands come out
// straight instead of warped. This is Hoskins' hash — same [0,1) distribution,
// no huge intermediate magnitudes.
float hash(float x, float y) {
vec3 p = fract(vec3(x, y, x) * 0.1031);
p += dot(p, p.yzx + 33.33);
return fract((p.x + p.y) * p.z);
}
float sstep(float t) {
return t * t * (3.0 - 2.0 * t);
}
// Bilinear value noise, matching the JS vnoise().
float vnoise(float x, float y) {
float xi = floor(x);
float yi = floor(y);
float u = sstep(x - xi);
float v = sstep(y - yi);
float a = hash(xi, yi);
float b = hash(xi + 1.0, yi);
float c = hash(xi, yi + 1.0);
float d = hash(xi + 1.0, yi + 1.0);
float top = a + (b - a) * u;
float bottom = c + (d - c) * u;
return top + (bottom - top) * v;
}
// Recursive construction of the 4x4 Bayer matrix, normalised to [0,1).
// Produces exactly BAYER[(y%4)*4 + (x%4)] / 16 from the original script.
float bayer2(vec2 a) {
a = floor(a);
return fract(a.x * 0.5 + a.y * a.y * 0.75);
}
float bayer4(vec2 a) {
return bayer2(a * 0.5) * 0.25 + bayer2(a);
}
void main() {
// Snap to the low-resolution grid the original canvas drew on.
vec2 grid = max(floor(uResolution / uPixel), vec2(2.0));
vec2 cell = floor(gl_FragCoord.xy / uPixel);
float u = cell.x / grid.x;
// The JS loop walks rows top-down with v = 1 - y/h; gl_FragCoord.y counts up.
float v = cell.y / grid.y;
float rowFromTop = grid.y - 1.0 - cell.y;
float n = (vnoise(u * 1.5 + uTime * 0.05, v * 1.5 + uTime * 0.03) * 2.0 - 1.0) * 0.25;
float g = (u + v) * 0.5 * 1.2 + n;
float ci = step(0.3, g) + step(0.55, g) + step(0.8, g);
float dither = bayer4(vec2(cell.x, rowFromTop));
float thr = fract(g * 4.0);
// Nudge up one palette step where the dither threshold allows it.
ci = min(ci + step(ci, 2.5) * step(dither * 0.5, thr), 3.0);
vec3 col = mix(mix(mix(uPal0, uPal1, step(0.5, ci)), uPal2, step(1.5, ci)), uPal3, step(2.5, ci));
float fade = clamp(length(vec2(u, v)) / 0.25, 0.0, 1.0);
float f = sstep(fade);
gl_FragColor = vec4(mix(vec3(1.0), col, f), 1.0);
}
`;
function hexToRgb(hex: string): [number, number, number] {
let h = hex.replace('#', '');
if (h.length === 3) {
h = h
.split('')
.map((c) => c + c)
.join('');
}
return [
parseInt(h.slice(0, 2), 16) / 255,
parseInt(h.slice(2, 4), 16) / 255,
parseInt(h.slice(4, 6), 16) / 255,
];
}
function mix(
a: [number, number, number],
b: [number, number, number],
t: number,
): [number, number, number] {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function compile(gl: ExpoWebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type)!;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw new Error(`dither shader: ${gl.getShaderInfoLog(shader)}`);
}
return shader;
}
export type DitherBackgroundProps = {
/** Deep end of the gradient. */
color1?: string;
/** Pale end of the gradient. */
color2?: string;
/** Animation rate multiplier. */
speed?: number;
/** Size of one dither cell in points. */
pixel?: number;
style?: ViewProps['style'];
};
export function DitherBackground({
color1 = '#2340D6',
color2 = '#EAF2FF',
speed = 1,
pixel = 3,
style,
}: DitherBackgroundProps) {
// Keep the latest props in a ref so the render loop never needs restarting.
const params = useRef({ color1, color2, speed, pixel });
params.current = { color1, color2, speed, pixel };
const frame = useRef<number | null>(null);
useEffect(() => {
return () => {
if (frame.current !== null) cancelAnimationFrame(frame.current);
};
}, []);
const onContextCreate = useCallback((gl: ExpoWebGLRenderingContext) => {
const program = gl.createProgram()!;
gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERT));
gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(program);
gl.useProgram(program);
// Full-screen triangle pair.
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
gl.STATIC_DRAW,
);
const position = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
const uResolution = gl.getUniformLocation(program, 'uResolution');
const uTime = gl.getUniformLocation(program, 'uTime');
const uPixel = gl.getUniformLocation(program, 'uPixel');
const uPal = [0, 1, 2, 3].map((i) => gl.getUniformLocation(program, `uPal${i}`));
const { drawingBufferWidth: width, drawingBufferHeight: height } = gl;
gl.viewport(0, 0, width, height);
gl.uniform2f(uResolution, width, height);
// The design's `pixel` is in points; scale it into framebuffer pixels so
// the cells stay the same visual size on a retina screen.
const density = PixelRatio.get();
let lastColors = '';
const start = Date.now();
let lastDraw = 0;
const render = () => {
frame.current = requestAnimationFrame(render);
const now = Date.now();
// The original throttled itself to ~25fps; the drift is slow enough that
// anything faster is wasted work.
if (now - lastDraw < 40) return;
lastDraw = now;
const p = params.current;
const key = `${p.color1}|${p.color2}`;
if (key !== lastColors) {
lastColors = key;
const deep = hexToRgb(p.color1);
const pale = hexToRgb(p.color2);
const palette = [deep, mix(deep, pale, 0.33), mix(deep, pale, 0.66), pale];
palette.forEach((c, i) => gl.uniform3f(uPal[i], c[0], c[1], c[2]));
}
gl.uniform1f(uPixel, Math.max(1, p.pixel * density));
gl.uniform1f(uTime, ((now - start) / 1000) * p.speed);
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.endFrameEXP();
};
render();
}, []);
return (
<View style={[StyleSheet.absoluteFill, style]} pointerEvents="none">
<GLView style={StyleSheet.absoluteFill} onContextCreate={onContextCreate} />
</View>
);
}

122
src/components/Icons.tsx Normal file
View File

@@ -0,0 +1,122 @@
import Svg, { Circle, Path, Rect } from 'react-native-svg';
/** Icon set traced from the inline SVGs in the design doc. */
type IconProps = {
size?: number;
color?: string;
/** Some icons are drawn heavier in the design (the check is 2.4). */
strokeWidth?: number;
};
export function CameraIcon({ size = 24, color = '#F2F6FE', strokeWidth = 2 }: IconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Rect
x={2.5}
y={6.5}
width={19}
height={13}
rx={4}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
<Circle cx={12} cy={13} r={3.6} stroke={color} strokeWidth={strokeWidth} />
<Path
d="M8.5 6.5l1.4-2h4.2l1.4 2"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
</Svg>
);
}
export function EnvelopeIcon({ size = 18, color = '#2340D6', strokeWidth = 2 }: IconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Rect
x={3}
y={5}
width={18}
height={14}
rx={3}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
<Path
d="M3.5 7l8.5 6 8.5-6"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}
export function CheckIcon({ size = 18, color = '#7C93C9', strokeWidth = 2.4 }: IconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path
d="M4 12.5l5 5L20 6.5"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}
export function SearchIcon({ size = 17, color = '#6B7A99', strokeWidth = 2 }: IconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Circle cx={11} cy={11} r={7} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
<Path d="M20 20l-3.5-3.5" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
</Svg>
);
}
export function FlashIcon({ size = 16, color = '#F2F6FE', strokeWidth = 2 }: IconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path
d="M13 3L5 13h5l-1 8 8-10h-5l1-8z"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}
export function GalleryIcon({ size = 20, color = '#F2F6FE', strokeWidth = 2 }: IconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Rect
x={3}
y={3}
width={18}
height={18}
rx={4}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
<Circle cx={9} cy={9} r={2} stroke={color} strokeWidth={strokeWidth} />
<Path
d="M21 15l-5-4-9 8"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}

View File

@@ -0,0 +1,82 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors, font, radius, shadow } from '../theme';
import type { Letter } from '../data/letters';
import { CheckIcon, EnvelopeIcon } from './Icons';
type Props = {
letter: Letter;
/** The archive shows "Sender · Kind" and a date prefix; Home shows neither. */
variant?: 'home' | 'archive';
onPress?: () => void;
};
export function LetterRow({ letter, variant = 'home', onPress }: Props) {
const needsAction = letter.deadlineDays !== undefined;
const title =
variant === 'archive' && letter.kind ? `${letter.sender} · ${letter.kind}` : letter.sender;
const subtitle =
variant === 'archive' ? `${letter.date} · ${letter.summary}` : letter.summary;
const badge =
variant === 'archive'
? needsAction
? `${letter.deadlineDays} Tage`
: null
: needsAction
? 'Frist'
: null;
return (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.pressed]}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={`${title}. ${subtitle}`}
>
<View style={[styles.tile, { backgroundColor: needsAction ? colors.tintStrong : colors.tint }]}>
{needsAction ? <EnvelopeIcon color={colors.blue} /> : <CheckIcon />}
</View>
<View style={styles.text}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.subtitle}>{subtitle}</Text>
</View>
{badge ? (
<View style={styles.badge}>
<Text style={styles.badgeText}>{badge}</Text>
</View>
) : null}
</Pressable>
);
}
const styles = StyleSheet.create({
row: {
backgroundColor: colors.surface,
borderRadius: radius.xl,
paddingVertical: 14,
paddingHorizontal: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
...shadow.card,
},
pressed: { opacity: 0.7 },
tile: {
width: 40,
height: 40,
borderRadius: radius.md,
alignItems: 'center',
justifyContent: 'center',
},
text: { flex: 1 },
title: { fontFamily: font.extrabold, fontSize: 14.5, color: colors.ink },
subtitle: { fontFamily: font.semibold, fontSize: 12, color: colors.slate, marginTop: 1 },
badge: {
backgroundColor: colors.tintStrong,
borderRadius: radius.pill,
paddingVertical: 5,
paddingHorizontal: 10,
},
badgeText: { fontFamily: font.extrabold, fontSize: 11, color: colors.blueInk },
});

95
src/data/letters.ts Normal file
View File

@@ -0,0 +1,95 @@
/** Mock content, lifted verbatim from the design doc so the screens read the same. */
export type Letter = {
id: string;
/** Sender, e.g. "Jobcenter". */
sender: string;
/** Document kind shown after the sender in the archive, e.g. "Bescheid". */
kind?: string;
/** Plain-language one-liner. */
summary: string;
/** Date label used in the archive list. */
date: string;
/** Month bucket the archive groups by. */
month: string;
/** Days left on the deadline, if the letter has one. */
deadlineDays?: number;
/** Whether anything is still expected of the user. */
done: boolean;
};
export const letters: Letter[] = [
{
id: 'jobcenter-bescheid',
sender: 'Jobcenter',
kind: 'Bescheid',
summary: 'Du bekommst mehr Geld',
date: 'Heute',
month: 'JULI 2026',
deadlineDays: 8,
done: false,
},
{
id: 'finanzamt-info',
sender: 'Finanzamt',
kind: 'Info',
summary: 'Nur zur Info nichts zu tun',
date: '12. Juli',
month: 'JULI 2026',
done: true,
},
{
id: 'stadtwerke-rechnung',
sender: 'Stadtwerke',
kind: 'Rechnung',
summary: 'Bezahlt',
date: '4. Juli',
month: 'JULI 2026',
done: true,
},
{
id: 'krankenkasse-brief',
sender: 'Krankenkasse',
kind: 'Brief',
summary: 'Karte kam per Post',
date: '28. Juni',
month: 'JUNI 2026',
done: true,
},
{
id: 'vermieter-schreiben',
sender: 'Vermieter',
kind: 'Schreiben',
summary: 'Nebenkosten erklärt',
date: '15. Juni',
month: 'JUNI 2026',
done: true,
},
];
/** Archive list grouped into the month headings the design shows. */
export function lettersByMonth(): { month: string; items: Letter[] }[] {
const groups: { month: string; items: Letter[] }[] = [];
for (const letter of letters) {
const last = groups[groups.length - 1];
if (last && last.month === letter.month) last.items.push(letter);
else groups.push({ month: letter.month, items: [letter] });
}
return groups;
}
export const archiveFilters = ['Alle', 'Mit Frist', 'Erledigt', 'Ämter'] as const;
export type ArchiveFilter = (typeof archiveFilters)[number];
export function filterLetters(filter: ArchiveFilter): Letter[] {
switch (filter) {
case 'Mit Frist':
return letters.filter((l) => l.deadlineDays !== undefined);
case 'Erledigt':
return letters.filter((l) => l.done);
case 'Ämter':
return letters.filter((l) => ['Jobcenter', 'Finanzamt'].includes(l.sender));
default:
return letters;
}
}

139
src/theme.ts Normal file
View File

@@ -0,0 +1,139 @@
/**
* Design tokens for Dokio, extracted from "Dokio Design.dc.html" (turns 3 + 4).
* Turn 4 introduced the deeper blue (#2340D6) for dithered surfaces; flat blue
* surfaces from turn 3 keep the lighter #3B5BE8.
*/
export const colors = {
/** Dithered surfaces — the deep end of the gradient. */
blueDeep: '#2340D6',
/** Flat blue surfaces: nav pill, filter chips, primary buttons. */
blue: '#3B5BE8',
/** Text on light backgrounds when it needs to read as blue. */
blueInk: '#2743CF',
/** Pale end of the dither gradient on the onboarding screen. */
blueMist: '#B9CFF9',
/** Pale end of the dither gradient inside the home scan card. */
blueHaze: '#7D9BF0',
/** Secondary numeral in stat cards. */
blueSoft: '#8AA3EE',
/** Camera-screen accent: corner brackets. */
blueBright: '#6C8CFF',
/** App background. */
canvas: '#EEF3FC',
surface: '#FFFFFF',
/** Tinted icon tile / badge background. */
tintStrong: '#DCE7FB',
/** Neutral icon tile background for "nothing to do" rows. */
tint: '#E6EEFB',
/** Inactive progress-bar segment. */
track: '#D6DFF3',
ink: '#22304F',
inkMuted: '#44547A',
/** Secondary copy. */
slate: '#6B7A99',
/** Placeholder text and inactive tab labels. */
slateLight: '#9FAECB',
/** Muted check icons. */
slateIcon: '#7C93C9',
/** Deadline sub-copy inside the result card. */
slateBlue: '#5A6FA8',
/** Text on blue. */
onBlue: '#F2F6FE',
/** Camera screen. */
night: '#141B2E',
viewfinderFrom: '#3A4258',
viewfinderTo: '#262E44',
/** "Brief erkannt" status dot. */
green: '#5BE884',
/** Paper mock inside the viewfinder. */
paper: '#E9E5DC',
paperInkStrong: '#C8C2B4',
paperInk: '#D5D0C4',
} as const;
export const font = {
/** Headings — Bricolage Grotesque, always weight 800 in the design. */
display: 'BricolageGrotesque_800ExtraBold',
/** Serif accent, currently just the Home greeting. */
serif: 'Fraunces_800ExtraBold',
/** Body — Nunito Sans. */
regular: 'NunitoSans_400Regular',
semibold: 'NunitoSans_600SemiBold',
bold: 'NunitoSans_700Bold',
extrabold: 'NunitoSans_800ExtraBold',
} as const;
export const radius = {
sm: 6,
md: 14,
lg: 16,
xl: 18,
'2xl': 20,
'3xl': 24,
'4xl': 28,
'5xl': 32,
pill: 99,
} as const;
export const space = {
/** Horizontal page gutter used by every screen. */
gutter: 24,
} as const;
/** Card elevation used across list rows and stat tiles. */
export const shadow = {
card: {
shadowColor: '#22304F',
shadowOpacity: 0.06,
shadowRadius: 8,
shadowOffset: { width: 0, height: 2 },
elevation: 2,
},
cardSoft: {
shadowColor: '#22304F',
shadowOpacity: 0.07,
shadowRadius: 10,
shadowOffset: { width: 0, height: 2 },
elevation: 3,
},
/** The blue scan card on Home. */
blueCard: {
shadowColor: '#2340D6',
shadowOpacity: 0.32,
shadowRadius: 26,
shadowOffset: { width: 0, height: 10 },
elevation: 10,
},
/** Floating scan button in the tab bar. */
fab: {
shadowColor: '#3B5BE8',
shadowOpacity: 0.4,
shadowRadius: 16,
shadowOffset: { width: 0, height: 6 },
elevation: 8,
},
/** Tab bar lip. */
tabBar: {
shadowColor: '#22304F',
shadowOpacity: 0.06,
shadowRadius: 16,
shadowOffset: { width: 0, height: -4 },
elevation: 12,
},
} as const;
/**
* The design mocks white text over the dither gradient with a soft glow so it
* stays legible against the pale end of the ramp.
*/
export const glow = {
textShadowColor: 'rgba(16,30,90,0.35)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 10,
} as const;

6
tsconfig.json Normal file
View File

@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}