feat: initial Simpl-Liste MVP

Task management app with Expo/React Native:
- 3 tabs: Inbox, Lists, Settings
- Task CRUD with subtasks, priorities, due dates
- SQLite database via Drizzle ORM
- i18n FR/EN (French default)
- Dark mode support (light/dark/system)
- Simpl- brand color palette (bleu/crème/terracotta)
- NativeWind (Tailwind) styling
- EAS Build config for Android (APK + AAB)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-02-20 19:28:42 -05:00
parent 0dc15a8c25
commit 0526a47900
45 changed files with 4350 additions and 438 deletions

View file

@ -1,6 +1,6 @@
{
"expo": {
"name": "simpl-liste",
"name": "Simpl-Liste",
"slug": "simpl-liste",
"version": "1.0.0",
"orientation": "portrait",
@ -11,26 +11,26 @@
"splash": {
"image": "./assets/images/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
"backgroundColor": "#FFF8F0"
},
"ios": {
"supportsTablet": true
"supportsTablet": true,
"bundleIdentifier": "com.lacompagniemaximus.simpliste"
},
"android": {
"package": "com.lacompagniemaximus.simpliste",
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
"backgroundColor": "#FFF8F0"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
"edgeToEdgeEnabled": true
},
"plugins": [
"expo-router"
"expo-router",
"expo-sqlite",
"expo-font",
"expo-localization",
"@react-native-community/datetimepicker"
],
"experiments": {
"typedRoutes": true

View file

@ -1,57 +1,54 @@
import React from 'react';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { Link, Tabs } from 'expo-router';
import { Pressable } from 'react-native';
import { Tabs } from 'expo-router';
import { useColorScheme } from 'react-native';
import { Inbox, List, Settings } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import Colors from '@/constants/Colors';
import { useColorScheme } from '@/components/useColorScheme';
import { useClientOnlyValue } from '@/components/useClientOnlyValue';
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
function TabBarIcon(props: {
name: React.ComponentProps<typeof FontAwesome>['name'];
color: string;
}) {
return <FontAwesome size={28} style={{ marginBottom: -3 }} {...props} />;
}
import { colors } from '@/src/theme/colors';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
export default function TabLayout() {
const colorScheme = useColorScheme();
const { t } = useTranslation();
const systemScheme = useColorScheme();
const theme = useSettingsStore((s) => s.theme);
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
// Disable the static render of the header on web
// to prevent a hydration error in React Navigation v6.
headerShown: useClientOnlyValue(false, true),
}}>
tabBarActiveTintColor: colors.bleu.DEFAULT,
tabBarInactiveTintColor: isDark ? colors.dark.textSecondary : colors.light.textSecondary,
tabBarStyle: {
backgroundColor: isDark ? colors.dark.surface : colors.light.surface,
borderTopColor: isDark ? colors.dark.border : colors.light.border,
},
headerStyle: {
backgroundColor: isDark ? colors.dark.surface : colors.light.surface,
},
headerTintColor: isDark ? colors.dark.text : colors.light.text,
headerTitleStyle: {
fontFamily: 'Inter_600SemiBold',
},
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Tab One',
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
headerRight: () => (
<Link href="/modal" asChild>
<Pressable>
{({ pressed }) => (
<FontAwesome
name="info-circle"
size={25}
color={Colors[colorScheme ?? 'light'].text}
style={{ marginRight: 15, opacity: pressed ? 0.5 : 1 }}
/>
)}
</Pressable>
</Link>
),
title: t('nav.inbox'),
tabBarIcon: ({ color, size }) => <Inbox size={size} color={color} />,
}}
/>
<Tabs.Screen
name="two"
name="lists"
options={{
title: 'Tab Two',
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
title: t('nav.lists'),
tabBarIcon: ({ color, size }) => <List size={size} color={color} />,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: t('nav.settings'),
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
}}
/>
</Tabs>

View file

@ -1,31 +1,105 @@
import { StyleSheet } from 'react-native';
import { useEffect, useState, useCallback } from 'react';
import { View, Text, FlatList, Pressable, useColorScheme, Alert } from 'react-native';
import { useRouter } from 'expo-router';
import { Plus } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
import { colors } from '@/src/theme/colors';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { getTasksByList, toggleComplete, deleteTask } from '@/src/db/repository/tasks';
import { getInboxId } from '@/src/db/repository/lists';
import TaskItem from '@/src/components/task/TaskItem';
type Task = {
id: string;
title: string;
completed: boolean;
priority: number;
dueDate: Date | null;
position: number;
};
export default function InboxScreen() {
const { t } = useTranslation();
const router = useRouter();
const [tasks, setTasks] = useState<Task[]>([]);
const systemScheme = useColorScheme();
const theme = useSettingsStore((s) => s.theme);
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
const loadTasks = useCallback(async () => {
const result = await getTasksByList(getInboxId());
setTasks(result as Task[]);
}, []);
useEffect(() => {
loadTasks();
}, [loadTasks]);
useEffect(() => {
const interval = setInterval(loadTasks, 500);
return () => clearInterval(interval);
}, [loadTasks]);
const handleToggle = async (id: string) => {
await toggleComplete(id);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
loadTasks();
};
const handleDelete = async (id: string) => {
Alert.alert(t('task.deleteConfirm'), '', [
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.delete'),
style: 'destructive',
onPress: async () => {
await deleteTask(id);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
loadTasks();
},
},
]);
};
export default function TabOneScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Tab One</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/(tabs)/index.tsx" />
<View className={`flex-1 ${isDark ? 'bg-[#1A1A1A]' : 'bg-creme'}`}>
{tasks.length === 0 ? (
<View className="flex-1 items-center justify-center px-8">
<Text
className={`text-center text-base ${
isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'
}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{t('empty.inbox')}
</Text>
</View>
) : (
<FlatList
data={tasks}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingBottom: 100 }}
renderItem={({ item }) => (
<TaskItem
task={item}
isDark={isDark}
onToggle={() => handleToggle(item.id)}
onPress={() => router.push(`/task/${item.id}` as any)}
onDelete={() => handleDelete(item.id)}
/>
)}
/>
)}
<Pressable
onPress={() => router.push('/task/new' as any)}
className="absolute bottom-6 right-6 h-14 w-14 items-center justify-center rounded-full bg-bleu"
style={{ elevation: 4, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.25, shadowRadius: 4 }}
>
<Plus size={28} color="#FFFFFF" />
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});

143
app/(tabs)/lists.tsx Normal file
View file

@ -0,0 +1,143 @@
import { useEffect, useState, useCallback } from 'react';
import { View, Text, FlatList, Pressable, useColorScheme, TextInput, Alert } from 'react-native';
import { useRouter } from 'expo-router';
import { Plus, ChevronRight, Trash2 } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import { colors } from '@/src/theme/colors';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { getAllLists, createList, deleteList } from '@/src/db/repository/lists';
import { getTasksByList } from '@/src/db/repository/tasks';
type ListWithCount = {
id: string;
name: string;
color: string | null;
isInbox: boolean;
taskCount: number;
};
export default function ListsScreen() {
const { t } = useTranslation();
const router = useRouter();
const [lists, setLists] = useState<ListWithCount[]>([]);
const [showNewInput, setShowNewInput] = useState(false);
const [newName, setNewName] = useState('');
const systemScheme = useColorScheme();
const theme = useSettingsStore((s) => s.theme);
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
const loadLists = useCallback(async () => {
const allLists = await getAllLists();
const withCounts = await Promise.all(
allLists.map(async (list) => {
const tasks = await getTasksByList(list.id);
return { ...list, taskCount: tasks.length };
})
);
setLists(withCounts);
}, []);
useEffect(() => {
loadLists();
}, [loadLists]);
useEffect(() => {
const interval = setInterval(loadLists, 500);
return () => clearInterval(interval);
}, [loadLists]);
const handleCreateList = async () => {
if (!newName.trim()) return;
await createList(newName.trim());
setNewName('');
setShowNewInput(false);
loadLists();
};
const handleDeleteList = (id: string, name: string) => {
Alert.alert(t('list.deleteConfirm'), '', [
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.delete'),
style: 'destructive',
onPress: async () => {
await deleteList(id);
loadLists();
},
},
]);
};
return (
<View className={`flex-1 ${isDark ? 'bg-[#1A1A1A]' : 'bg-creme'}`}>
<FlatList
data={lists}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingBottom: 100 }}
renderItem={({ item }) => (
<Pressable
onPress={() => router.push(`/task/new?listId=${item.id}`)}
className={`flex-row items-center justify-between border-b px-4 py-4 ${
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
>
<View className="flex-1 flex-row items-center">
<View
className="mr-3 h-3 w-3 rounded-full"
style={{ backgroundColor: item.color || colors.bleu.DEFAULT }}
/>
<Text
className={`text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_500Medium' }}
>
{item.isInbox ? t('list.inbox') : item.name}
</Text>
</View>
<View className="flex-row items-center">
<Text className={`mr-2 text-sm ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}>
{item.taskCount}
</Text>
{!item.isInbox && (
<Pressable onPress={() => handleDeleteList(item.id, item.name)} className="mr-2 p-1">
<Trash2 size={16} color={colors.terracotta.DEFAULT} />
</Pressable>
)}
<ChevronRight size={16} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</View>
</Pressable>
)}
ListFooterComponent={
showNewInput ? (
<View className="flex-row items-center px-4 py-3">
<TextInput
autoFocus
value={newName}
onChangeText={setNewName}
onSubmitEditing={handleCreateList}
onBlur={() => { setShowNewInput(false); setNewName(''); }}
placeholder={t('list.namePlaceholder')}
placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'}
className={`flex-1 rounded-lg border px-3 py-2 text-base ${
isDark
? 'border-[#3A3A3A] bg-[#2A2A2A] text-[#F5F5F5]'
: 'border-[#E5E7EB] bg-white text-[#1A1A1A]'
}`}
style={{ fontFamily: 'Inter_400Regular' }}
/>
</View>
) : null
}
/>
{/* FAB */}
<Pressable
onPress={() => setShowNewInput(true)}
className="absolute bottom-6 right-6 h-14 w-14 items-center justify-center rounded-full bg-bleu"
style={{ elevation: 4, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.25, shadowRadius: 4 }}
>
<Plus size={28} color="#FFFFFF" />
</Pressable>
</View>
);
}

147
app/(tabs)/settings.tsx Normal file
View file

@ -0,0 +1,147 @@
import { View, Text, Pressable, useColorScheme } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Sun, Moon, Smartphone } from 'lucide-react-native';
import Constants from 'expo-constants';
import { colors } from '@/src/theme/colors';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import i18n from '@/src/i18n';
type ThemeMode = 'light' | 'dark' | 'system';
export default function SettingsScreen() {
const { t } = useTranslation();
const systemScheme = useColorScheme();
const { theme, locale, setTheme, setLocale } = useSettingsStore();
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
const themeOptions: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
{ value: 'light', label: t('settings.light'), icon: Sun },
{ value: 'dark', label: t('settings.dark'), icon: Moon },
{ value: 'system', label: t('settings.system'), icon: Smartphone },
];
const handleLocaleChange = (newLocale: 'fr' | 'en') => {
setLocale(newLocale);
i18n.changeLanguage(newLocale);
};
return (
<View className={`flex-1 ${isDark ? 'bg-[#1A1A1A]' : 'bg-creme'}`}>
{/* Theme Section */}
<View className="px-4 pt-6">
<Text
className={`mb-3 text-xs uppercase tracking-wide ${
isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'
}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('settings.theme')}
</Text>
<View
className={`overflow-hidden rounded-xl ${
isDark ? 'bg-[#2A2A2A]' : 'bg-white'
}`}
>
{themeOptions.map((option) => {
const Icon = option.icon;
const isActive = theme === option.value;
return (
<Pressable
key={option.value}
onPress={() => setTheme(option.value)}
className={`flex-row items-center border-b px-4 py-3.5 ${
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
} ${isActive ? (isDark ? 'bg-[#3A3A3A]' : 'bg-creme-dark') : ''}`}
>
<Icon
size={20}
color={isActive ? colors.bleu.DEFAULT : isDark ? '#A0A0A0' : '#6B6B6B'}
/>
<Text
className={`ml-3 text-base ${
isActive
? 'text-bleu'
: isDark
? 'text-[#F5F5F5]'
: 'text-[#1A1A1A]'
}`}
style={{ fontFamily: isActive ? 'Inter_600SemiBold' : 'Inter_400Regular' }}
>
{option.label}
</Text>
</Pressable>
);
})}
</View>
</View>
{/* Language Section */}
<View className="px-4 pt-6">
<Text
className={`mb-3 text-xs uppercase tracking-wide ${
isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'
}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('settings.language')}
</Text>
<View
className={`overflow-hidden rounded-xl ${
isDark ? 'bg-[#2A2A2A]' : 'bg-white'
}`}
>
{(['fr', 'en'] as const).map((lang) => {
const isActive = locale === lang;
return (
<Pressable
key={lang}
onPress={() => handleLocaleChange(lang)}
className={`border-b px-4 py-3.5 ${
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
} ${isActive ? (isDark ? 'bg-[#3A3A3A]' : 'bg-creme-dark') : ''}`}
>
<Text
className={`text-base ${
isActive
? 'text-bleu'
: isDark
? 'text-[#F5F5F5]'
: 'text-[#1A1A1A]'
}`}
style={{ fontFamily: isActive ? 'Inter_600SemiBold' : 'Inter_400Regular' }}
>
{lang === 'fr' ? 'Français' : 'English'}
</Text>
</Pressable>
);
})}
</View>
</View>
{/* About Section */}
<View className="px-4 pt-6">
<Text
className={`mb-3 text-xs uppercase tracking-wide ${
isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'
}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('settings.about')}
</Text>
<View
className={`overflow-hidden rounded-xl px-4 py-3.5 ${
isDark ? 'bg-[#2A2A2A]' : 'bg-white'
}`}
>
<Text className={`text-sm ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}>
Simpl-Liste {t('settings.version')} {Constants.expoConfig?.version ?? '1.0.0'}
</Text>
<Text className={`mt-1 text-xs ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}>
La Compagnie Maximus
</Text>
</View>
</View>
</View>
);
}

View file

@ -1,31 +0,0 @@
import { StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function TabTwoScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Tab Two</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/(tabs)/two.tsx" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});

View file

@ -1,38 +0,0 @@
import { ScrollViewStyleReset } from 'expo-router/html';
// This file is web-only and used to configure the root HTML for every
// web page during static rendering.
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;

View file

@ -1,17 +1,14 @@
import { Link, Stack } from 'expo-router';
import { StyleSheet } from 'react-native';
import { Text, View } from '@/components/Themed';
import { View, Text, StyleSheet } from 'react-native';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<View style={styles.container}>
<Text style={styles.title}>This screen doesn't exist.</Text>
<Text style={styles.title}>Page introuvable</Text>
<Link href="/" style={styles.link}>
<Text style={styles.linkText}>Go to home screen!</Text>
<Text style={styles.linkText}>Retour</Text>
</Link>
</View>
</>
@ -24,10 +21,12 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
padding: 20,
backgroundColor: '#FFF8F0',
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: '#1A1A1A',
},
link: {
marginTop: 15,
@ -35,6 +34,6 @@ const styles = StyleSheet.create({
},
linkText: {
fontSize: 14,
color: '#2e78b7',
color: '#4A90A4',
},
});

View file

@ -1,59 +1,93 @@
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react';
import 'react-native-reanimated';
import { useColorScheme } from 'react-native';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { Stack } from 'expo-router';
import { useFonts, Inter_400Regular, Inter_500Medium, Inter_600SemiBold, Inter_700Bold } from '@expo-google-fonts/inter';
import * as SplashScreen from 'expo-splash-screen';
import { useMigrations } from 'drizzle-orm/expo-sqlite/migrator';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { useColorScheme } from '@/components/useColorScheme';
import { db } from '@/src/db/client';
import migrations from '@/src/db/migrations/migrations';
import { ensureInbox } from '@/src/db/repository/lists';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import '@/src/i18n';
import '@/src/global.css';
export {
// Catch any errors thrown by the Layout component.
ErrorBoundary,
} from 'expo-router';
export { ErrorBoundary } from 'expo-router';
export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: '(tabs)',
};
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
const SimplLightTheme = {
...DefaultTheme,
colors: {
...DefaultTheme.colors,
background: '#FFF8F0',
card: '#FFFFFF',
text: '#1A1A1A',
border: '#E5E7EB',
primary: '#4A90A4',
},
};
const SimplDarkTheme = {
...DarkTheme,
colors: {
...DarkTheme.colors,
background: '#1A1A1A',
card: '#2A2A2A',
text: '#F5F5F5',
border: '#3A3A3A',
primary: '#4A90A4',
},
};
export default function RootLayout() {
const [loaded, error] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
...FontAwesome.font,
const [fontsLoaded, fontError] = useFonts({
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
});
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
useEffect(() => {
if (error) throw error;
}, [error]);
const { success: migrationsReady, error: migrationError } = useMigrations(db, migrations);
const systemScheme = useColorScheme();
const theme = useSettingsStore((s) => s.theme);
const effectiveScheme = theme === 'system' ? systemScheme : theme;
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
if (fontError) throw fontError;
if (migrationError) throw migrationError;
}, [fontError, migrationError]);
useEffect(() => {
if (fontsLoaded && migrationsReady) {
ensureInbox().then(() => {
SplashScreen.hideAsync();
});
}
}, [loaded]);
}, [fontsLoaded, migrationsReady]);
if (!loaded) {
if (!fontsLoaded || !migrationsReady) {
return null;
}
return <RootLayoutNav />;
}
function RootLayoutNav() {
const colorScheme = useColorScheme();
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
</Stack>
</ThemeProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<ThemeProvider value={effectiveScheme === 'dark' ? SimplDarkTheme : SimplLightTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="task/new"
options={{ presentation: 'modal', headerShown: false }}
/>
<Stack.Screen
name="task/[id]"
options={{ headerShown: false }}
/>
</Stack>
</ThemeProvider>
</GestureHandlerRootView>
);
}

View file

@ -1,35 +0,0 @@
import { StatusBar } from 'expo-status-bar';
import { Platform, StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function ModalScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Modal</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/modal.tsx" />
{/* Use a light status bar on iOS to account for the black space above the modal */}
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});

346
app/task/[id].tsx Normal file
View file

@ -0,0 +1,346 @@
import { useEffect, useState } from 'react';
import {
View,
Text,
TextInput,
Pressable,
ScrollView,
useColorScheme,
Alert,
Platform,
} from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { ArrowLeft, Plus, Trash2, Calendar, X } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics';
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
import { colors } from '@/src/theme/colors';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import {
getTaskById,
updateTask,
deleteTask,
getSubtasks,
createTask,
toggleComplete,
} from '@/src/db/repository/tasks';
const priorityOptions = [
{ value: 0, labelKey: 'priority.none', color: colors.priority.none },
{ value: 1, labelKey: 'priority.low', color: colors.priority.low },
{ value: 2, labelKey: 'priority.medium', color: colors.priority.medium },
{ value: 3, labelKey: 'priority.high', color: colors.priority.high },
];
type TaskData = {
id: string;
title: string;
notes: string | null;
completed: boolean;
priority: number;
dueDate: Date | null;
listId: string;
};
type SubtaskData = {
id: string;
title: string;
completed: boolean;
};
export default function TaskDetailScreen() {
const { t } = useTranslation();
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const systemScheme = useColorScheme();
const theme = useSettingsStore((s) => s.theme);
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
const [task, setTask] = useState<TaskData | null>(null);
const [title, setTitle] = useState('');
const [notes, setNotes] = useState('');
const [priority, setPriority] = useState(0);
const [dueDate, setDueDate] = useState<Date | null>(null);
const [showDatePicker, setShowDatePicker] = useState(false);
const [subtasks, setSubtasks] = useState<SubtaskData[]>([]);
const [newSubtask, setNewSubtask] = useState('');
useEffect(() => {
if (!id) return;
loadTask();
loadSubtasks();
}, [id]);
const loadTask = async () => {
const result = await getTaskById(id!);
if (!result) return;
setTask(result as TaskData);
setTitle(result.title);
setNotes(result.notes ?? '');
setPriority(result.priority);
setDueDate(result.dueDate ? new Date(result.dueDate) : null);
};
const loadSubtasks = async () => {
const result = await getSubtasks(id!);
setSubtasks(result as SubtaskData[]);
};
const handleSave = async () => {
if (!task || !title.trim()) return;
await updateTask(task.id, {
title: title.trim(),
notes: notes.trim() || undefined,
priority,
dueDate,
});
router.back();
};
const handleDelete = () => {
Alert.alert(t('task.deleteConfirm'), '', [
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.delete'),
style: 'destructive',
onPress: async () => {
await deleteTask(id!);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
router.back();
},
},
]);
};
const handleAddSubtask = async () => {
if (!newSubtask.trim() || !task) return;
await createTask({
title: newSubtask.trim(),
listId: task.listId,
parentId: task.id,
});
setNewSubtask('');
loadSubtasks();
};
const handleToggleSubtask = async (subtaskId: string) => {
await toggleComplete(subtaskId);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
loadSubtasks();
};
const handleDateChange = (_: DateTimePickerEvent, date?: Date) => {
setShowDatePicker(Platform.OS === 'ios');
if (date) setDueDate(date);
};
if (!task) {
return (
<View className={`flex-1 items-center justify-center ${isDark ? 'bg-[#1A1A1A]' : 'bg-creme'}`}>
<Text className={isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}>...</Text>
</View>
);
}
return (
<View className={`flex-1 ${isDark ? 'bg-[#1A1A1A]' : 'bg-creme'}`}>
{/* Header */}
<View
className={`flex-row items-center justify-between border-b px-4 pb-3 pt-14 ${
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
>
<Pressable onPress={() => router.back()} className="p-1">
<ArrowLeft size={24} color={isDark ? '#F5F5F5' : '#1A1A1A'} />
</Pressable>
<View className="flex-row items-center">
<Pressable onPress={handleDelete} className="mr-3 p-1">
<Trash2 size={20} color={colors.terracotta.DEFAULT} />
</Pressable>
<Pressable onPress={handleSave} className="rounded-lg bg-bleu px-4 py-1.5">
<Text className="text-sm text-white" style={{ fontFamily: 'Inter_600SemiBold' }}>
{t('common.save')}
</Text>
</Pressable>
</View>
</View>
<ScrollView className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled">
{/* Title */}
<TextInput
value={title}
onChangeText={setTitle}
placeholder={t('task.titlePlaceholder')}
placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'}
className={`text-xl ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_500Medium' }}
/>
{/* Notes */}
<TextInput
value={notes}
onChangeText={setNotes}
placeholder={t('task.notesPlaceholder')}
placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'}
multiline
className={`mt-4 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular', minHeight: 60 }}
/>
{/* Priority */}
<Text
className={`mb-2 mt-6 text-xs uppercase tracking-wide ${
isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'
}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('task.priority')}
</Text>
<View className="flex-row">
{priorityOptions.map((opt) => (
<Pressable
key={opt.value}
onPress={() => setPriority(opt.value)}
className={`mr-2 rounded-full border px-3 py-1.5 ${
priority === opt.value
? 'border-transparent'
: isDark
? 'border-[#3A3A3A]'
: 'border-[#E5E7EB]'
}`}
style={priority === opt.value ? { backgroundColor: opt.color + '20' } : undefined}
>
<View className="flex-row items-center">
<View
className="mr-1.5 h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: opt.color }}
/>
<Text
className="text-sm"
style={{
fontFamily: priority === opt.value ? 'Inter_600SemiBold' : 'Inter_400Regular',
color:
priority === opt.value
? opt.color
: isDark
? '#A0A0A0'
: '#6B6B6B',
}}
>
{t(opt.labelKey)}
</Text>
</View>
</Pressable>
))}
</View>
{/* Due Date */}
<Text
className={`mb-2 mt-6 text-xs uppercase tracking-wide ${
isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'
}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('task.dueDate')}
</Text>
<Pressable
onPress={() => setShowDatePicker(true)}
className={`flex-row items-center rounded-lg border px-3 py-2.5 ${
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
>
<Calendar size={18} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
<Text
className={`ml-2 text-base ${
dueDate
? isDark
? 'text-[#F5F5F5]'
: 'text-[#1A1A1A]'
: isDark
? 'text-[#A0A0A0]'
: 'text-[#6B6B6B]'
}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{dueDate ? dueDate.toLocaleDateString() : t('task.dueDate')}
</Text>
{dueDate && (
<Pressable onPress={() => setDueDate(null)} className="ml-auto">
<X size={16} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>
)}
</Pressable>
{showDatePicker && (
<DateTimePicker
value={dueDate ?? new Date()}
mode="date"
display="default"
onChange={handleDateChange}
/>
)}
{/* Subtasks */}
<Text
className={`mb-2 mt-6 text-xs uppercase tracking-wide ${
isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'
}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('task.subtasks')}
</Text>
{subtasks.map((sub) => (
<Pressable
key={sub.id}
onPress={() => handleToggleSubtask(sub.id)}
className={`flex-row items-center border-b py-2.5 ${
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
>
<View
className="mr-3 h-5 w-5 items-center justify-center rounded-full border-2"
style={{
borderColor: sub.completed ? colors.bleu.DEFAULT : colors.priority.none,
backgroundColor: sub.completed ? colors.bleu.DEFAULT : 'transparent',
}}
>
{sub.completed && (
<Text className="text-xs text-white" style={{ fontFamily: 'Inter_700Bold' }}>
</Text>
)}
</View>
<Text
className={`text-base ${
sub.completed
? 'line-through ' + (isDark ? 'text-[#A0A0A0]' : 'text-[#9CA3AF]')
: isDark
? 'text-[#F5F5F5]'
: 'text-[#1A1A1A]'
}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{sub.title}
</Text>
</Pressable>
))}
{/* Add subtask */}
<View className="mt-2 flex-row items-center">
<Plus size={18} color={colors.bleu.DEFAULT} />
<TextInput
value={newSubtask}
onChangeText={setNewSubtask}
onSubmitEditing={handleAddSubtask}
placeholder={t('task.addSubtask')}
placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'}
className={`ml-2 flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
/>
</View>
<View className="h-24" />
</ScrollView>
</View>
);
}

252
app/task/new.tsx Normal file
View file

@ -0,0 +1,252 @@
import { useState, useEffect } from 'react';
import {
View,
Text,
TextInput,
Pressable,
ScrollView,
useColorScheme,
Platform,
} from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { X, Calendar } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
import { colors } from '@/src/theme/colors';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { createTask } from '@/src/db/repository/tasks';
import { getInboxId, getAllLists } from '@/src/db/repository/lists';
const priorityOptions = [
{ value: 0, labelKey: 'priority.none', color: colors.priority.none },
{ value: 1, labelKey: 'priority.low', color: colors.priority.low },
{ value: 2, labelKey: 'priority.medium', color: colors.priority.medium },
{ value: 3, labelKey: 'priority.high', color: colors.priority.high },
];
export default function NewTaskScreen() {
const { t } = useTranslation();
const router = useRouter();
const params = useLocalSearchParams<{ listId?: string }>();
const systemScheme = useColorScheme();
const theme = useSettingsStore((s) => s.theme);
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
const [title, setTitle] = useState('');
const [notes, setNotes] = useState('');
const [priority, setPriority] = useState(0);
const [dueDate, setDueDate] = useState<Date | null>(null);
const [showDatePicker, setShowDatePicker] = useState(false);
const [selectedListId, setSelectedListId] = useState(params.listId ?? getInboxId());
const [lists, setLists] = useState<{ id: string; name: string; isInbox: boolean }[]>([]);
useEffect(() => {
getAllLists().then(setLists);
}, []);
const handleSave = async () => {
if (!title.trim()) return;
await createTask({
title: title.trim(),
notes: notes.trim() || undefined,
priority,
dueDate: dueDate ?? undefined,
listId: selectedListId,
});
router.back();
};
const handleDateChange = (_: DateTimePickerEvent, date?: Date) => {
setShowDatePicker(Platform.OS === 'ios');
if (date) setDueDate(date);
};
return (
<View className={`flex-1 ${isDark ? 'bg-[#1A1A1A]' : 'bg-creme'}`}>
{/* Header */}
<View
className={`flex-row items-center justify-between border-b px-4 pb-3 pt-14 ${
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
>
<Pressable onPress={() => router.back()} className="p-1">
<X size={24} color={isDark ? '#F5F5F5' : '#1A1A1A'} />
</Pressable>
<Text
className={`text-lg ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('task.newTask')}
</Text>
<Pressable onPress={handleSave} className="rounded-lg bg-bleu px-4 py-1.5">
<Text className="text-sm text-white" style={{ fontFamily: 'Inter_600SemiBold' }}>
{t('common.save')}
</Text>
</Pressable>
</View>
<ScrollView className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled">
{/* Title */}
<TextInput
autoFocus
value={title}
onChangeText={setTitle}
placeholder={t('task.titlePlaceholder')}
placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'}
className={`text-xl ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_500Medium' }}
/>
{/* Notes */}
<TextInput
value={notes}
onChangeText={setNotes}
placeholder={t('task.notesPlaceholder')}
placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'}
multiline
className={`mt-4 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular', minHeight: 60 }}
/>
{/* Priority */}
<Text
className={`mb-2 mt-6 text-xs uppercase tracking-wide ${
isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'
}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('task.priority')}
</Text>
<View className="flex-row">
{priorityOptions.map((opt) => (
<Pressable
key={opt.value}
onPress={() => setPriority(opt.value)}
className={`mr-2 rounded-full border px-3 py-1.5 ${
priority === opt.value
? 'border-transparent'
: isDark
? 'border-[#3A3A3A]'
: 'border-[#E5E7EB]'
}`}
style={priority === opt.value ? { backgroundColor: opt.color + '20' } : undefined}
>
<View className="flex-row items-center">
<View
className="mr-1.5 h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: opt.color }}
/>
<Text
className={`text-sm ${
priority === opt.value
? ''
: isDark
? 'text-[#A0A0A0]'
: 'text-[#6B6B6B]'
}`}
style={{
fontFamily: priority === opt.value ? 'Inter_600SemiBold' : 'Inter_400Regular',
color: priority === opt.value ? opt.color : undefined,
}}
>
{t(opt.labelKey)}
</Text>
</View>
</Pressable>
))}
</View>
{/* Due Date */}
<Text
className={`mb-2 mt-6 text-xs uppercase tracking-wide ${
isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'
}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('task.dueDate')}
</Text>
<Pressable
onPress={() => setShowDatePicker(true)}
className={`flex-row items-center rounded-lg border px-3 py-2.5 ${
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
>
<Calendar size={18} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
<Text
className={`ml-2 text-base ${
dueDate
? isDark
? 'text-[#F5F5F5]'
: 'text-[#1A1A1A]'
: isDark
? 'text-[#A0A0A0]'
: 'text-[#6B6B6B]'
}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{dueDate ? dueDate.toLocaleDateString() : t('task.dueDate')}
</Text>
{dueDate && (
<Pressable onPress={() => setDueDate(null)} className="ml-auto">
<X size={16} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>
)}
</Pressable>
{showDatePicker && (
<DateTimePicker
value={dueDate ?? new Date()}
mode="date"
display="default"
onChange={handleDateChange}
/>
)}
{/* List selector */}
{lists.length > 1 && (
<>
<Text
className={`mb-2 mt-6 text-xs uppercase tracking-wide ${
isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'
}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('nav.lists')}
</Text>
<View className="flex-row flex-wrap">
{lists.map((list) => (
<Pressable
key={list.id}
onPress={() => setSelectedListId(list.id)}
className={`mb-2 mr-2 rounded-full border px-3 py-1.5 ${
selectedListId === list.id
? 'border-bleu bg-bleu/10'
: isDark
? 'border-[#3A3A3A]'
: 'border-[#E5E7EB]'
}`}
>
<Text
className={`text-sm ${
selectedListId === list.id
? 'text-bleu'
: isDark
? 'text-[#A0A0A0]'
: 'text-[#6B6B6B]'
}`}
style={{
fontFamily:
selectedListId === list.id ? 'Inter_600SemiBold' : 'Inter_400Regular',
}}
>
{list.isInbox ? t('list.inbox') : list.name}
</Text>
</Pressable>
))}
</View>
</>
)}
</ScrollView>
</View>
);
}

11
babel.config.js Normal file
View file

@ -0,0 +1,11 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
],
plugins: [
"react-native-reanimated/plugin",
],
};
};

View file

@ -1,77 +0,0 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import { ExternalLink } from './ExternalLink';
import { MonoText } from './StyledText';
import { Text, View } from './Themed';
import Colors from '@/constants/Colors';
export default function EditScreenInfo({ path }: { path: string }) {
return (
<View>
<View style={styles.getStartedContainer}>
<Text
style={styles.getStartedText}
lightColor="rgba(0,0,0,0.8)"
darkColor="rgba(255,255,255,0.8)">
Open up the code for this screen:
</Text>
<View
style={[styles.codeHighlightContainer, styles.homeScreenFilename]}
darkColor="rgba(255,255,255,0.05)"
lightColor="rgba(0,0,0,0.05)">
<MonoText>{path}</MonoText>
</View>
<Text
style={styles.getStartedText}
lightColor="rgba(0,0,0,0.8)"
darkColor="rgba(255,255,255,0.8)">
Change any of the text, save the file, and your app will automatically update.
</Text>
</View>
<View style={styles.helpContainer}>
<ExternalLink
style={styles.helpLink}
href="https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet">
<Text style={styles.helpLinkText} lightColor={Colors.light.tint}>
Tap here if your app doesn't automatically update after making changes
</Text>
</ExternalLink>
</View>
</View>
);
}
const styles = StyleSheet.create({
getStartedContainer: {
alignItems: 'center',
marginHorizontal: 50,
},
homeScreenFilename: {
marginVertical: 7,
},
codeHighlightContainer: {
borderRadius: 3,
paddingHorizontal: 4,
},
getStartedText: {
fontSize: 17,
lineHeight: 24,
textAlign: 'center',
},
helpContainer: {
marginTop: 15,
marginHorizontal: 20,
alignItems: 'center',
},
helpLink: {
paddingVertical: 15,
},
helpLinkText: {
textAlign: 'center',
},
});

View file

@ -1,25 +0,0 @@
import { Link } from 'expo-router';
import * as WebBrowser from 'expo-web-browser';
import React from 'react';
import { Platform } from 'react-native';
export function ExternalLink(
props: Omit<React.ComponentProps<typeof Link>, 'href'> & { href: string }
) {
return (
<Link
target="_blank"
{...props}
// @ts-expect-error: External URLs are not typed.
href={props.href}
onPress={(e) => {
if (Platform.OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
e.preventDefault();
// Open the link in an in-app browser.
WebBrowser.openBrowserAsync(props.href as string);
}
}}
/>
);
}

View file

@ -1,5 +0,0 @@
import { Text, TextProps } from './Themed';
export function MonoText(props: TextProps) {
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
}

View file

@ -1,45 +0,0 @@
/**
* Learn more about Light and Dark modes:
* https://docs.expo.io/guides/color-schemes/
*/
import { Text as DefaultText, View as DefaultView } from 'react-native';
import Colors from '@/constants/Colors';
import { useColorScheme } from './useColorScheme';
type ThemeProps = {
lightColor?: string;
darkColor?: string;
};
export type TextProps = ThemeProps & DefaultText['props'];
export type ViewProps = ThemeProps & DefaultView['props'];
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}
export function Text(props: TextProps) {
const { style, lightColor, darkColor, ...otherProps } = props;
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return <DefaultText style={[{ color }, style]} {...otherProps} />;
}
export function View(props: ViewProps) {
const { style, lightColor, darkColor, ...otherProps } = props;
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <DefaultView style={[{ backgroundColor }, style]} {...otherProps} />;
}

View file

@ -1,10 +0,0 @@
import * as React from 'react';
import renderer from 'react-test-renderer';
import { MonoText } from '../StyledText';
it(`renders correctly`, () => {
const tree = renderer.create(<MonoText>Snapshot test!</MonoText>).toJSON();
expect(tree).toMatchSnapshot();
});

View file

@ -1,4 +0,0 @@
// This function is web-only as native doesn't currently support server (or build-time) rendering.
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
return client;
}

View file

@ -1,12 +0,0 @@
import React from 'react';
// `useEffect` is not invoked during server rendering, meaning
// we can use this to determine if we're on the server or not.
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
const [value, setValue] = React.useState<S | C>(server);
React.useEffect(() => {
setValue(client);
}, [client]);
return value;
}

View file

@ -1 +0,0 @@
export { useColorScheme } from 'react-native';

View file

@ -1,8 +0,0 @@
// NOTE: The default React Native styling doesn't support server rendering.
// Server rendered styles should not change between the first render of the HTML
// and the first render on the client. Typically, web developers will use CSS media queries
// to render different styles on the client and server, these aren't directly supported in React Native
// but can be achieved using a styling library like Nativewind.
export function useColorScheme() {
return 'light';
}

View file

@ -1,19 +0,0 @@
const tintColorLight = '#2f95dc';
const tintColorDark = '#fff';
export default {
light: {
text: '#000',
background: '#fff',
tint: tintColorLight,
tabIconDefault: '#ccc',
tabIconSelected: tintColorLight,
},
dark: {
text: '#fff',
background: '#000',
tint: tintColorDark,
tabIconDefault: '#ccc',
tabIconSelected: tintColorDark,
},
};

8
drizzle.config.ts Normal file
View file

@ -0,0 +1,8 @@
import type { Config } from 'drizzle-kit';
export default {
dialect: 'sqlite',
driver: 'expo',
schema: './src/db/schema.ts',
out: './src/db/migrations',
} satisfies Config;

33
eas.json Normal file
View file

@ -0,0 +1,33 @@
{
"cli": {
"version": ">= 15.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": {
"autoIncrement": true,
"android": {
"buildType": "app-bundle"
}
}
},
"submit": {
"production": {
"android": {
"track": "production"
}
}
}
}

9
metro.config.js Normal file
View file

@ -0,0 +1,9 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
// Add SQL extension for Drizzle migrations
config.resolver.sourceExts.push("sql");
module.exports = withNativeWind(config, { input: "./src/global.css" });

1
nativewind-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="nativewind/types" />

2381
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,28 +9,45 @@
"web": "expo start --web"
},
"dependencies": {
"@expo-google-fonts/inter": "^0.4.2",
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-navigation/native": "^7.1.8",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.45.1",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-linking": "~8.0.11",
"expo-localization": "~17.0.8",
"expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.13",
"expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.10",
"i18next": "^25.8.13",
"lucide-react-native": "^0.575.0",
"nativewind": "^4.2.2",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^16.5.4",
"react-native": "0.81.5",
"react-native-worklets": "0.5.1",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0"
"react-native-svg": "15.12.1",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1",
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/react": "~19.1.0",
"drizzle-kit": "^0.31.9",
"react-test-renderer": "19.1.0",
"tailwindcss": "^3.4.17",
"typescript": "~5.9.2"
},
"private": true

View file

@ -0,0 +1,92 @@
import { View, Text, Pressable } from 'react-native';
import { Check, Trash2 } from 'lucide-react-native';
import { format } from 'date-fns';
import { fr, enUS } from 'date-fns/locale';
import { useTranslation } from 'react-i18next';
import { colors } from '@/src/theme/colors';
const priorityColors = [
colors.priority.none,
colors.priority.low,
colors.priority.medium,
colors.priority.high,
];
interface TaskItemProps {
task: {
id: string;
title: string;
completed: boolean;
priority: number;
dueDate: Date | null;
};
isDark: boolean;
onToggle: () => void;
onPress: () => void;
onDelete: () => void;
}
export default function TaskItem({ task, isDark, onToggle, onPress, onDelete }: TaskItemProps) {
const { i18n } = useTranslation();
const dateLocale = i18n.language === 'fr' ? fr : enUS;
return (
<Pressable
onPress={onPress}
className={`flex-row items-center border-b px-4 py-3.5 ${
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
>
{/* Checkbox */}
<Pressable
onPress={onToggle}
className={`mr-3 h-6 w-6 items-center justify-center rounded-full border-2`}
style={{
borderColor: task.completed ? colors.bleu.DEFAULT : priorityColors[task.priority] ?? colors.priority.none,
backgroundColor: task.completed ? colors.bleu.DEFAULT : 'transparent',
}}
>
{task.completed && <Check size={14} color="#FFFFFF" strokeWidth={3} />}
</Pressable>
{/* Content */}
<View className="flex-1">
<Text
className={`text-base ${
task.completed
? 'line-through ' + (isDark ? 'text-[#A0A0A0]' : 'text-[#9CA3AF]')
: isDark
? 'text-[#F5F5F5]'
: 'text-[#1A1A1A]'
}`}
style={{ fontFamily: 'Inter_400Regular' }}
numberOfLines={1}
>
{task.title}
</Text>
{task.dueDate && (
<Text
className={`mt-0.5 text-xs ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{format(new Date(task.dueDate), 'd MMM yyyy', { locale: dateLocale })}
</Text>
)}
</View>
{/* Priority dot */}
{task.priority > 0 && (
<View
className="ml-2 h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: priorityColors[task.priority] }}
/>
)}
{/* Delete */}
<Pressable onPress={onDelete} className="ml-3 p-1">
<Trash2 size={16} color={isDark ? '#A0A0A0' : '#9CA3AF'} />
</Pressable>
</Pressable>
);
}

6
src/db/client.ts Normal file
View file

@ -0,0 +1,6 @@
import { openDatabaseSync } from 'expo-sqlite';
import { drizzle } from 'drizzle-orm/expo-sqlite';
import * as schema from './schema';
const expoDb = openDatabaseSync('simpliste.db', { enableChangeListener: true });
export const db = drizzle(expoDb, { schema });

View file

@ -0,0 +1,26 @@
CREATE TABLE `lists` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`color` text,
`icon` text,
`position` integer DEFAULT 0 NOT NULL,
`is_inbox` integer DEFAULT false NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `tasks` (
`id` text PRIMARY KEY NOT NULL,
`title` text NOT NULL,
`notes` text,
`completed` integer DEFAULT false NOT NULL,
`completed_at` integer,
`priority` integer DEFAULT 0 NOT NULL,
`due_date` integer,
`list_id` text NOT NULL,
`parent_id` text,
`position` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`list_id`) REFERENCES `lists`(`id`) ON UPDATE no action ON DELETE no action
);

View file

@ -0,0 +1,197 @@
{
"version": "6",
"dialect": "sqlite",
"id": "67121461-af03-441b-8c25-f88e630334d7",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"lists": {
"name": "lists",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"is_inbox": {
"name": "is_inbox",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tasks": {
"name": "tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed": {
"name": "completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"priority": {
"name": "priority",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"due_date": {
"name": "due_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"list_id": {
"name": "list_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"tasks_list_id_lists_id_fk": {
"name": "tasks_list_id_lists_id_fk",
"tableFrom": "tasks",
"tableTo": "lists",
"columnsFrom": [
"list_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1771632828781,
"tag": "0000_bitter_phalanx",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,12 @@
// This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo
import journal from './meta/_journal.json';
import m0000 from './0000_bitter_phalanx.sql';
export default {
journal,
migrations: {
m0000
}
}

View file

@ -0,0 +1,58 @@
import { eq } from 'drizzle-orm';
import { db } from '../client';
import { lists } from '../schema';
const INBOX_ID = '00000000-0000-0000-0000-000000000001';
export async function ensureInbox() {
const existing = await db.select().from(lists).where(eq(lists.id, INBOX_ID));
if (existing.length === 0) {
const now = new Date();
await db.insert(lists).values({
id: INBOX_ID,
name: 'Inbox',
isInbox: true,
position: 0,
createdAt: now,
updatedAt: now,
});
}
return INBOX_ID;
}
export function getInboxId() {
return INBOX_ID;
}
export async function getAllLists() {
return db.select().from(lists).orderBy(lists.position);
}
export async function createList(name: string, color?: string) {
const now = new Date();
const id = crypto.randomUUID();
const allLists = await getAllLists();
const maxPosition = allLists.reduce((max, l) => Math.max(max, l.position), 0);
await db.insert(lists).values({
id,
name,
color: color ?? null,
position: maxPosition + 1,
isInbox: false,
createdAt: now,
updatedAt: now,
});
return id;
}
export async function updateList(id: string, data: { name?: string; color?: string }) {
await db
.update(lists)
.set({ ...data, updatedAt: new Date() })
.where(eq(lists.id, id));
}
export async function deleteList(id: string) {
await db.delete(lists).where(eq(lists.id, id));
}

View file

@ -0,0 +1,91 @@
import { eq, and, isNull, desc, asc } from 'drizzle-orm';
import { db } from '../client';
import { tasks } from '../schema';
export async function getTasksByList(listId: string) {
return db
.select()
.from(tasks)
.where(and(eq(tasks.listId, listId), isNull(tasks.parentId)))
.orderBy(asc(tasks.position), desc(tasks.createdAt));
}
export async function getSubtasks(parentId: string) {
return db
.select()
.from(tasks)
.where(eq(tasks.parentId, parentId))
.orderBy(asc(tasks.position));
}
export async function getTaskById(id: string) {
const result = await db.select().from(tasks).where(eq(tasks.id, id));
return result[0] ?? null;
}
export async function createTask(data: {
title: string;
listId: string;
notes?: string;
priority?: number;
dueDate?: Date;
parentId?: string;
}) {
const now = new Date();
const id = crypto.randomUUID();
const siblings = data.parentId
? await getSubtasks(data.parentId)
: await getTasksByList(data.listId);
const maxPosition = siblings.reduce((max, t) => Math.max(max, t.position), 0);
await db.insert(tasks).values({
id,
title: data.title,
notes: data.notes ?? null,
priority: data.priority ?? 0,
dueDate: data.dueDate ?? null,
listId: data.listId,
parentId: data.parentId ?? null,
completed: false,
position: maxPosition + 1,
createdAt: now,
updatedAt: now,
});
return id;
}
export async function updateTask(
id: string,
data: {
title?: string;
notes?: string;
priority?: number;
dueDate?: Date | null;
listId?: string;
completed?: boolean;
}
) {
const updates: Record<string, unknown> = { ...data, updatedAt: new Date() };
if (data.completed === true) {
updates.completedAt = new Date();
} else if (data.completed === false) {
updates.completedAt = null;
}
await db.update(tasks).set(updates).where(eq(tasks.id, id));
}
export async function toggleComplete(id: string) {
const task = await getTaskById(id);
if (!task) return;
await updateTask(id, { completed: !task.completed });
}
export async function deleteTask(id: string) {
// Delete subtasks first
const subtasks = await getSubtasks(id);
for (const sub of subtasks) {
await db.delete(tasks).where(eq(tasks.id, sub.id));
}
await db.delete(tasks).where(eq(tasks.id, id));
}

27
src/db/schema.ts Normal file
View file

@ -0,0 +1,27 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
export const lists = sqliteTable('lists', {
id: text('id').primaryKey(),
name: text('name').notNull(),
color: text('color'),
icon: text('icon'),
position: integer('position').notNull().default(0),
isInbox: integer('is_inbox', { mode: 'boolean' }).notNull().default(false),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const tasks = sqliteTable('tasks', {
id: text('id').primaryKey(),
title: text('title').notNull(),
notes: text('notes'),
completed: integer('completed', { mode: 'boolean' }).notNull().default(false),
completedAt: integer('completed_at', { mode: 'timestamp' }),
priority: integer('priority').notNull().default(0), // 0=none, 1=low, 2=medium, 3=high
dueDate: integer('due_date', { mode: 'timestamp' }),
listId: text('list_id').notNull().references(() => lists.id),
parentId: text('parent_id'),
position: integer('position').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});

3
src/global.css Normal file
View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

57
src/i18n/en.json Normal file
View file

@ -0,0 +1,57 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"done": "Done",
"confirm": "Confirm"
},
"task": {
"title": "Title",
"titlePlaceholder": "Task name...",
"notes": "Notes",
"notesPlaceholder": "Add notes...",
"dueDate": "Due date",
"priority": "Priority",
"subtasks": "Subtasks",
"addSubtask": "Add a subtask",
"completed": "Completed",
"newTask": "New task",
"deleteConfirm": "Are you sure you want to delete this task?"
},
"priority": {
"none": "None",
"low": "Low",
"medium": "Medium",
"high": "High"
},
"nav": {
"inbox": "Inbox",
"lists": "Lists",
"settings": "Settings"
},
"list": {
"inbox": "Inbox",
"newList": "New list",
"namePlaceholder": "List name...",
"deleteConfirm": "Are you sure you want to delete this list?",
"taskCount_one": "{{count}} task",
"taskCount_other": "{{count}} tasks"
},
"settings": {
"title": "Settings",
"theme": "Theme",
"language": "Language",
"dark": "Dark",
"light": "Light",
"system": "System",
"about": "About",
"version": "Version"
},
"empty": {
"inbox": "No tasks yet.\nTap + to get started.",
"list": "This list is empty."
}
}

57
src/i18n/fr.json Normal file
View file

@ -0,0 +1,57 @@
{
"common": {
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"edit": "Modifier",
"add": "Ajouter",
"done": "Terminé",
"confirm": "Confirmer"
},
"task": {
"title": "Titre",
"titlePlaceholder": "Nom de la tâche...",
"notes": "Notes",
"notesPlaceholder": "Ajouter des notes...",
"dueDate": "Date d'échéance",
"priority": "Priorité",
"subtasks": "Sous-tâches",
"addSubtask": "Ajouter une sous-tâche",
"completed": "Terminée",
"newTask": "Nouvelle tâche",
"deleteConfirm": "Voulez-vous vraiment supprimer cette tâche ?"
},
"priority": {
"none": "Aucune",
"low": "Basse",
"medium": "Moyenne",
"high": "Haute"
},
"nav": {
"inbox": "Boîte de réception",
"lists": "Listes",
"settings": "Paramètres"
},
"list": {
"inbox": "Boîte de réception",
"newList": "Nouvelle liste",
"namePlaceholder": "Nom de la liste...",
"deleteConfirm": "Voulez-vous vraiment supprimer cette liste ?",
"taskCount_one": "{{count}} tâche",
"taskCount_other": "{{count}} tâches"
},
"settings": {
"title": "Paramètres",
"theme": "Thème",
"language": "Langue",
"dark": "Sombre",
"light": "Clair",
"system": "Système",
"about": "À propos",
"version": "Version"
},
"empty": {
"inbox": "Aucune tâche.\nAppuyez sur + pour commencer.",
"list": "Cette liste est vide."
}
}

23
src/i18n/index.ts Normal file
View file

@ -0,0 +1,23 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import * as Localization from 'expo-localization';
import fr from './fr.json';
import en from './en.json';
const deviceLang = Localization.getLocales()[0]?.languageCode ?? 'fr';
const initialLang = deviceLang === 'en' ? 'en' : 'fr';
i18n.use(initReactI18next).init({
resources: {
fr: { translation: fr },
en: { translation: en },
},
lng: initialLang,
fallbackLng: 'fr',
interpolation: {
escapeValue: false,
},
});
export default i18n;

View file

@ -0,0 +1,27 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
type ThemeMode = 'light' | 'dark' | 'system';
interface SettingsState {
theme: ThemeMode;
locale: 'fr' | 'en';
setTheme: (theme: ThemeMode) => void;
setLocale: (locale: 'fr' | 'en') => void;
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: 'system',
locale: 'fr',
setTheme: (theme) => set({ theme }),
setLocale: (locale) => set({ locale }),
}),
{
name: 'simpl-liste-settings',
storage: createJSONStorage(() => AsyncStorage),
}
)
);

36
src/theme/colors.ts Normal file
View file

@ -0,0 +1,36 @@
export const colors = {
bleu: {
DEFAULT: '#4A90A4',
light: '#6BAEC2',
dark: '#3A7389',
},
creme: {
DEFAULT: '#FFF8F0',
dark: '#F5EDE3',
},
terracotta: {
DEFAULT: '#C17767',
light: '#D49585',
dark: '#A45F50',
},
priority: {
high: '#C17767',
medium: '#4A90A4',
low: '#8BA889',
none: '#9CA3AF',
},
light: {
background: '#FFF8F0',
surface: '#FFFFFF',
text: '#1A1A1A',
textSecondary: '#6B6B6B',
border: '#E5E7EB',
},
dark: {
background: '#1A1A1A',
surface: '#2A2A2A',
text: '#F5F5F5',
textSecondary: '#A0A0A0',
border: '#3A3A3A',
},
} as const;

49
tailwind.config.js Normal file
View file

@ -0,0 +1,49 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,jsx,ts,tsx}",
"./src/**/*.{js,jsx,ts,tsx}",
],
presets: [require("nativewind/preset")],
theme: {
extend: {
colors: {
bleu: {
DEFAULT: '#4A90A4',
light: '#6BAEC2',
dark: '#3A7389',
},
creme: {
DEFAULT: '#FFF8F0',
dark: '#F5EDE3',
},
terracotta: {
DEFAULT: '#C17767',
light: '#D49585',
dark: '#A45F50',
},
surface: {
light: '#FFFFFF',
dark: '#2A2A2A',
},
border: {
light: '#E5E7EB',
dark: '#3A3A3A',
},
priority: {
high: '#C17767',
medium: '#4A90A4',
low: '#8BA889',
none: '#9CA3AF',
},
},
fontFamily: {
inter: ['Inter_400Regular'],
'inter-medium': ['Inter_500Medium'],
'inter-semibold': ['Inter_600SemiBold'],
'inter-bold': ['Inter_700Bold'],
},
},
},
plugins: [],
};