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:
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal 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
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"expo@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal 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
3
AGENTS.md
Normal 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.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
32
app.json
Normal 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
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 },
|
||||
});
|
||||
42
app/_layout.tsx
Normal file
42
app/_layout.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { BricolageGrotesque_800ExtraBold } from '@expo-google-fonts/bricolage-grotesque';
|
||||
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,
|
||||
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
6
app/index.tsx
Normal 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
142
app/onboarding.tsx
Normal 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
242
app/scan.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
BIN
assets/android-icon-background.png
Normal file
BIN
assets/android-icon-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/android-icon-foreground.png
Normal file
BIN
assets/android-icon-foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
BIN
assets/android-icon-monochrome.png
Normal file
BIN
assets/android-icon-monochrome.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/favicon.png
Normal file
BIN
assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
BIN
assets/splash-icon.png
Normal file
BIN
assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
7867
package-lock.json
generated
Normal file
7867
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "dokio",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/bricolage-grotesque": "^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
|
||||
}
|
||||
237
src/components/DitherBackground.tsx
Normal file
237
src/components/DitherBackground.tsx
Normal 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
122
src/components/Icons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
src/components/LetterRow.tsx
Normal file
82
src/components/LetterRow.tsx
Normal 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
95
src/data/letters.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
137
src/theme.ts
Normal file
137
src/theme.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 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',
|
||||
/** 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
6
tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user