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:
parent
0dc15a8c25
commit
0526a47900
45 changed files with 4350 additions and 438 deletions
24
app.json
24
app.json
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
143
app/(tabs)/lists.tsx
Normal 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
147
app/(tabs)/settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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%',
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}`;
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
114
app/_layout.tsx
114
app/_layout.tsx
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
346
app/task/[id].tsx
Normal 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
252
app/task/new.tsx
Normal 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
11
babel.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [
|
||||
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||
],
|
||||
plugins: [
|
||||
"react-native-reanimated/plugin",
|
||||
],
|
||||
};
|
||||
};
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { Text, TextProps } from './Themed';
|
||||
|
||||
export function MonoText(props: TextProps) {
|
||||
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { useColorScheme } from 'react-native';
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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
8
drizzle.config.ts
Normal 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
33
eas.json
Normal 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
9
metro.config.js
Normal 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
1
nativewind-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="nativewind/types" />
|
||||
2381
package-lock.json
generated
2381
package-lock.json
generated
File diff suppressed because it is too large
Load diff
21
package.json
21
package.json
|
|
@ -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
|
||||
|
|
|
|||
92
src/components/task/TaskItem.tsx
Normal file
92
src/components/task/TaskItem.tsx
Normal 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
6
src/db/client.ts
Normal 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 });
|
||||
26
src/db/migrations/0000_bitter_phalanx.sql
Normal file
26
src/db/migrations/0000_bitter_phalanx.sql
Normal 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
|
||||
);
|
||||
197
src/db/migrations/meta/0000_snapshot.json
Normal file
197
src/db/migrations/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
13
src/db/migrations/meta/_journal.json
Normal file
13
src/db/migrations/meta/_journal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1771632828781,
|
||||
"tag": "0000_bitter_phalanx",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
12
src/db/migrations/migrations.js
Normal file
12
src/db/migrations/migrations.js
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
58
src/db/repository/lists.ts
Normal file
58
src/db/repository/lists.ts
Normal 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));
|
||||
}
|
||||
91
src/db/repository/tasks.ts
Normal file
91
src/db/repository/tasks.ts
Normal 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
27
src/db/schema.ts
Normal 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
3
src/global.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
57
src/i18n/en.json
Normal file
57
src/i18n/en.json
Normal 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
57
src/i18n/fr.json
Normal 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
23
src/i18n/index.ts
Normal 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;
|
||||
27
src/stores/useSettingsStore.ts
Normal file
27
src/stores/useSettingsStore.ts
Normal 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
36
src/theme/colors.ts
Normal 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
49
tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
Loading…
Reference in a new issue