From 0526a479002ab8d70fda905b99526ab2dbe1b0c5 Mon Sep 17 00:00:00 2001 From: le king fu Date: Fri, 20 Feb 2026 19:28:42 -0500 Subject: [PATCH] feat: initial Simpl-Liste MVP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app.json | 24 +- app/(tabs)/_layout.tsx | 77 +- app/(tabs)/index.tsx | 124 +- app/(tabs)/lists.tsx | 143 ++ app/(tabs)/settings.tsx | 147 ++ app/(tabs)/two.tsx | 31 - app/+html.tsx | 38 - app/+not-found.tsx | 13 +- app/_layout.tsx | 114 +- app/modal.tsx | 35 - app/task/[id].tsx | 346 +++ app/task/new.tsx | 252 +++ babel.config.js | 11 + components/EditScreenInfo.tsx | 77 - components/ExternalLink.tsx | 25 - components/StyledText.tsx | 5 - components/Themed.tsx | 45 - components/__tests__/StyledText-test.js | 10 - components/useClientOnlyValue.ts | 4 - components/useClientOnlyValue.web.ts | 12 - components/useColorScheme.ts | 1 - components/useColorScheme.web.ts | 8 - constants/Colors.ts | 19 - drizzle.config.ts | 8 + eas.json | 33 + metro.config.js | 9 + nativewind-env.d.ts | 1 + package-lock.json | 2381 ++++++++++++++++++++- package.json | 21 +- src/components/task/TaskItem.tsx | 92 + src/db/client.ts | 6 + src/db/migrations/0000_bitter_phalanx.sql | 26 + src/db/migrations/meta/0000_snapshot.json | 197 ++ src/db/migrations/meta/_journal.json | 13 + src/db/migrations/migrations.js | 12 + src/db/repository/lists.ts | 58 + src/db/repository/tasks.ts | 91 + src/db/schema.ts | 27 + src/global.css | 3 + src/i18n/en.json | 57 + src/i18n/fr.json | 57 + src/i18n/index.ts | 23 + src/stores/useSettingsStore.ts | 27 + src/theme/colors.ts | 36 + tailwind.config.js | 49 + 45 files changed, 4350 insertions(+), 438 deletions(-) create mode 100644 app/(tabs)/lists.tsx create mode 100644 app/(tabs)/settings.tsx delete mode 100644 app/(tabs)/two.tsx delete mode 100644 app/+html.tsx delete mode 100644 app/modal.tsx create mode 100644 app/task/[id].tsx create mode 100644 app/task/new.tsx create mode 100644 babel.config.js delete mode 100644 components/EditScreenInfo.tsx delete mode 100644 components/ExternalLink.tsx delete mode 100644 components/StyledText.tsx delete mode 100644 components/Themed.tsx delete mode 100644 components/__tests__/StyledText-test.js delete mode 100644 components/useClientOnlyValue.ts delete mode 100644 components/useClientOnlyValue.web.ts delete mode 100644 components/useColorScheme.ts delete mode 100644 components/useColorScheme.web.ts delete mode 100644 constants/Colors.ts create mode 100644 drizzle.config.ts create mode 100644 eas.json create mode 100644 metro.config.js create mode 100644 nativewind-env.d.ts create mode 100644 src/components/task/TaskItem.tsx create mode 100644 src/db/client.ts create mode 100644 src/db/migrations/0000_bitter_phalanx.sql create mode 100644 src/db/migrations/meta/0000_snapshot.json create mode 100644 src/db/migrations/meta/_journal.json create mode 100644 src/db/migrations/migrations.js create mode 100644 src/db/repository/lists.ts create mode 100644 src/db/repository/tasks.ts create mode 100644 src/db/schema.ts create mode 100644 src/global.css create mode 100644 src/i18n/en.json create mode 100644 src/i18n/fr.json create mode 100644 src/i18n/index.ts create mode 100644 src/stores/useSettingsStore.ts create mode 100644 src/theme/colors.ts create mode 100644 tailwind.config.js diff --git a/app.json b/app.json index bdf0055..4555060 100644 --- a/app.json +++ b/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 diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 30914fb..bad222c 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -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['name']; - color: string; -}) { - return ; -} +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 ( + 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', + }, + }} + > , - headerRight: () => ( - - - {({ pressed }) => ( - - )} - - - ), + title: t('nav.inbox'), + tabBarIcon: ({ color, size }) => , }} /> , + title: t('nav.lists'), + tabBarIcon: ({ color, size }) => , + }} + /> + , }} /> diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 6cbee6d..1ee075e 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -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([]); + 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 ( - - Tab One - - + + {tasks.length === 0 ? ( + + + {t('empty.inbox')} + + + ) : ( + item.id} + contentContainerStyle={{ paddingBottom: 100 }} + renderItem={({ item }) => ( + handleToggle(item.id)} + onPress={() => router.push(`/task/${item.id}` as any)} + onDelete={() => handleDelete(item.id)} + /> + )} + /> + )} + + 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 }} + > + + ); } - -const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, - title: { - fontSize: 20, - fontWeight: 'bold', - }, - separator: { - marginVertical: 30, - height: 1, - width: '80%', - }, -}); diff --git a/app/(tabs)/lists.tsx b/app/(tabs)/lists.tsx new file mode 100644 index 0000000..6a00577 --- /dev/null +++ b/app/(tabs)/lists.tsx @@ -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([]); + 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 ( + + item.id} + contentContainerStyle={{ paddingBottom: 100 }} + renderItem={({ item }) => ( + 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]' + }`} + > + + + + {item.isInbox ? t('list.inbox') : item.name} + + + + + {item.taskCount} + + {!item.isInbox && ( + handleDeleteList(item.id, item.name)} className="mr-2 p-1"> + + + )} + + + + )} + ListFooterComponent={ + showNewInput ? ( + + { 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' }} + /> + + ) : null + } + /> + + {/* FAB */} + 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 }} + > + + + + ); +} diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx new file mode 100644 index 0000000..a470ed3 --- /dev/null +++ b/app/(tabs)/settings.tsx @@ -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 ( + + {/* Theme Section */} + + + {t('settings.theme')} + + + {themeOptions.map((option) => { + const Icon = option.icon; + const isActive = theme === option.value; + return ( + 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') : ''}`} + > + + + {option.label} + + + ); + })} + + + + {/* Language Section */} + + + {t('settings.language')} + + + {(['fr', 'en'] as const).map((lang) => { + const isActive = locale === lang; + return ( + handleLocaleChange(lang)} + className={`border-b px-4 py-3.5 ${ + isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]' + } ${isActive ? (isDark ? 'bg-[#3A3A3A]' : 'bg-creme-dark') : ''}`} + > + + {lang === 'fr' ? 'Français' : 'English'} + + + ); + })} + + + + {/* About Section */} + + + {t('settings.about')} + + + + Simpl-Liste {t('settings.version')} {Constants.expoConfig?.version ?? '1.0.0'} + + + La Compagnie Maximus + + + + + ); +} diff --git a/app/(tabs)/two.tsx b/app/(tabs)/two.tsx deleted file mode 100644 index f2ea47e..0000000 --- a/app/(tabs)/two.tsx +++ /dev/null @@ -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 ( - - Tab Two - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, - title: { - fontSize: 20, - fontWeight: 'bold', - }, - separator: { - marginVertical: 30, - height: 1, - width: '80%', - }, -}); diff --git a/app/+html.tsx b/app/+html.tsx deleted file mode 100644 index cb31090..0000000 --- a/app/+html.tsx +++ /dev/null @@ -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 ( - - - - - - - {/* - 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. - */} - - - {/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */} -