Compare commits

..

No commits in common. "master" and "v1.0.0" have entirely different histories.

20 changed files with 698 additions and 949 deletions

View file

@ -1,7 +0,0 @@
---
paths: ["**/*.tsx", "**/*.ts"]
---
Toute chaine visible par l'utilisateur doit passer par i18n (react-i18next).
Fichiers : `src/i18n/fr.json` et `src/i18n/en.json`. Francais par defaut.
Jamais de texte en dur dans les composants React.
Toujours ajouter la cle dans les DEUX langues.

View file

@ -1,6 +0,0 @@
---
paths: ["**/migrations/**", "**/*.sql", "**/schema.ts"]
---
Ne JAMAIS modifier une migration SQL existante. Toujours creer une nouvelle migration.
Apres `npx drizzle-kit generate`, mettre a jour `src/db/migrations/migrations.js` si necessaire.
Les migrations sont auto-appliquees au demarrage via `useMigrations()`.

3
.gitignore vendored
View file

@ -31,8 +31,7 @@ yarn-error.*
*.pem
# local env files
.env
.env.*
.env*.local
# typescript
*.tsbuildinfo

View file

@ -61,21 +61,16 @@ src/
├── lib/
│ ├── priority.ts # Helpers couleurs priorité
│ ├── recurrence.ts # Types récurrence + calcul prochaine occurrence
│ ├── uuid.ts # Wrapper expo-crypto randomUUID
│ └── validation.ts # Validation UUID pour deep links
│ └── uuid.ts # Wrapper expo-crypto randomUUID
├── services/
│ ├── calendar.ts # Sync expo-calendar
│ ├── icsExport.ts # Export .ics + partage
│ ├── notifications.ts # Planification expo-notifications
│ └── widgetSync.ts # Sync tâches + thème vers widget Android
│ └── notifications.ts # Planification expo-notifications
├── stores/
│ ├── useSettingsStore.ts # Thème, locale, notifs, calendrier
│ └── useTaskStore.ts # État tri/filtre
├── theme/
│ └── colors.ts # Palette centralisée (bleu, crème, terracotta)
└── widgets/
├── TaskListWidget.tsx # Composant widget Android (3 tailles, dark mode)
└── widgetTaskHandler.ts # Handler headless pour actions widget
└── theme/
└── colors.ts # Palette centralisée (bleu, crème, terracotta)
```
## Base de données
@ -121,63 +116,6 @@ Puis mettre à jour `src/db/migrations/migrations.js` si nécessaire.
Couleurs sombres : fond `#1A1A1A`, surface `#2A2A2A`, bordure `#3A3A3A`, texte `#F5F5F5`, secondaire `#A0A0A0`
## Widget Android
## Build
3 tailles configurées dans `app.json` (plugin `react-native-android-widget`) :
- **SimplListeSmall** (2×2) — Compteur de tâches + bouton ajout
- **SimplListeMedium** (2×4) — Liste de 4 tâches avec indicateur couleur de liste
- **SimplListeLarge** (4×4) — Liste de 8 tâches
### Sync des données
- `widgetSync.ts` lit les tâches depuis SQLite et les cache dans AsyncStorage (`widget:tasks`)
- Le thème est lu depuis AsyncStorage (`simpl-liste-settings` → `state.theme`), résolu si `system` via `Appearance.getColorScheme()`, et stocké dans `widget:isDark`
- `widgetTaskHandler.ts` gère le rendu headless (quand l'app n'est pas ouverte) en lisant les deux clés AsyncStorage
- Les couleurs du widget suivent la même palette que l'app (voir `LIGHT_COLORS` / `DARK_COLORS` dans `TaskListWidget.tsx`)
### Clés AsyncStorage utilisées par le widget
| Clé | Contenu |
|-----|---------|
| `widget:tasks` | `WidgetTask[]` sérialisé JSON |
| `widget:isDark` | `boolean` sérialisé JSON |
| `simpl-liste-settings` | Store Zustand persisté (contient `state.theme`) |
## Build & déploiement
Profiles EAS dans `eas.json` :
- **development** — APK avec dev client
- **preview** — APK de distribution directe (hors Play Store)
- **production** — AAB pour le Play Store, `autoIncrement: true` sur `versionCode`
### Commandes de build
```bash
npx eas-cli build --platform android --profile preview --non-interactive # APK
npx eas-cli build --platform android --profile production --non-interactive # AAB
```
**Important** : `eas` n'est pas installé globalement, utiliser `npx --yes eas-cli` (pas `npx eas`).
### Processus de release
1. Bumper `version` dans `app.json` ET `package.json`
2. Le `versionCode` Android est auto-incrémenté par EAS (`autoIncrement: true`)
3. Build preview (APK) + production (AAB)
4. Créer la release sur Forgejo via API :
```bash
# Créer la release
curl -X POST ".../api/v1/repos/maximus/simpl-liste/releases" -d '{"tag_name":"vX.Y.Z",...}'
# Attacher l'APK
curl -X POST ".../releases/{id}/assets?name=simpl-liste-vX.Y.Z.apk" -F "attachment=@fichier.apk"
```
5. Le bouton « Vérifier les mises à jour » dans l'app utilise l'endpoint `/releases/latest` et propose le téléchargement de l'asset `.apk`
### Repo Forgejo
- URL : `https://git.lacompagniemaximus.com/maximus/simpl-liste`
- Remote git : `origin` (push via HTTPS avec token dans `~/.git-credentials`)
- Issues : utilisées pour le suivi des bugs/features
- Releases : distribution APK avec assets attachés
## Mises à jour in-app
Le bouton dans Paramètres > À propos appelle `GET /api/v1/repos/maximus/simpl-liste/releases/latest` (repo public, pas d'auth nécessaire). Compare `release.tag_name` (ex: `v1.0.1`) avec `Constants.expoConfig.version`. Si différent, affiche une Alert avec le changelog (`release.body`) et un lien vers le premier asset `.apk` trouvé.
Profiles EAS dans `eas.json` : dev / preview / production.

View file

@ -2,7 +2,7 @@
"expo": {
"name": "Simpl-Liste",
"slug": "simpl-liste",
"version": "1.3.0",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "simplliste",
@ -23,8 +23,7 @@
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#FFF8F0"
},
"edgeToEdgeEnabled": true,
"versionCode": 6
"edgeToEdgeEnabled": true
},
"plugins": [
"expo-router",

View file

@ -1,9 +1,8 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import {
View, Text, Pressable, useColorScheme, TextInput, Alert,
Modal, Platform, ScrollView,
Modal, KeyboardAvoidingView, Platform, ScrollView,
} from 'react-native';
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
import { useRouter } from 'expo-router';
import {
Plus, ChevronRight, Check, GripVertical,
@ -250,8 +249,8 @@ export default function ListsScreen() {
{/* Create/Edit Modal */}
<Modal visible={showModal} transparent animationType="fade">
<KeyboardAvoidingView
behavior="padding"
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
>
<Pressable onPress={() => setShowModal(false)} className="flex-1 justify-center bg-black/40 px-6">
<Pressable

View file

@ -1,15 +1,13 @@
import { useState, useEffect, useCallback } from 'react';
import { View, Text, Pressable, useColorScheme, TextInput, ScrollView, Alert, Modal, Platform, Switch, Linking, ActivityIndicator } from 'react-native';
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
import { View, Text, Pressable, useColorScheme, TextInput, ScrollView, Alert, Modal, KeyboardAvoidingView, Platform, Switch } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Sun, Moon, Smartphone, Plus, Trash2, Pencil, Bell, CalendarDays, LayoutGrid, Mail, RefreshCw } from 'lucide-react-native';
import { Sun, Moon, Smartphone, Plus, Trash2, Pencil, Bell, CalendarDays } from 'lucide-react-native';
import Constants from 'expo-constants';
import { colors } from '@/src/theme/colors';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { getAllTags, createTag, updateTag, deleteTag } from '@/src/db/repository/tags';
import { initCalendar } from '@/src/services/calendar';
import { syncWidgetData } from '@/src/services/widgetSync';
import i18n from '@/src/i18n';
type ThemeMode = 'light' | 'dark' | 'system';
@ -24,7 +22,6 @@ export default function SettingsScreen() {
notificationsEnabled, setNotificationsEnabled,
reminderOffset, setReminderOffset,
calendarSyncEnabled, setCalendarSyncEnabled,
widgetPeriodWeeks, setWidgetPeriodWeeks,
} = useSettingsStore();
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
@ -33,7 +30,6 @@ export default function SettingsScreen() {
const [editingTagId, setEditingTagId] = useState<string | null>(null);
const [tagName, setTagName] = useState('');
const [tagColor, setTagColor] = useState(TAG_COLORS[0]);
const [checkingUpdate, setCheckingUpdate] = useState(false);
const loadTags = useCallback(async () => {
const result = await getAllTags();
@ -94,45 +90,6 @@ export default function SettingsScreen() {
]);
};
const handleCheckUpdate = async () => {
setCheckingUpdate(true);
try {
const res = await fetch(
'https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-liste/releases/latest'
);
if (!res.ok) throw new Error('API error');
const release = await res.json();
const latestTag: string = release.tag_name ?? '';
const latestVersion = latestTag.replace(/^v/, '');
const currentVersion = Constants.expoConfig?.version ?? '0.0.0';
if (latestVersion && latestVersion !== currentVersion) {
const apkAsset = release.assets?.find(
(a: { name: string }) => a.name?.endsWith('.apk')
);
const downloadUrl = apkAsset?.browser_download_url;
const body = release.body ? `\n\n${release.body}` : '';
Alert.alert(
t('settings.checkUpdate'),
t('settings.newVersion', { version: latestVersion }) + body,
[
{ text: t('common.cancel'), style: 'cancel' },
...(downloadUrl
? [{ text: t('settings.download'), onPress: () => Linking.openURL(downloadUrl) }]
: []),
]
);
} else {
Alert.alert(t('settings.checkUpdate'), t('settings.upToDate'));
}
} catch {
Alert.alert(t('settings.checkUpdate'), t('settings.updateError'));
} finally {
setCheckingUpdate(false);
}
};
return (
<ScrollView className={`flex-1 ${isDark ? 'bg-[#1A1A1A]' : 'bg-creme'}`}>
{/* Theme Section */}
@ -301,56 +258,6 @@ export default function SettingsScreen() {
</View>
</View>
{/* Widget 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('widget.title')}
</Text>
<View className={`overflow-hidden rounded-xl ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`}>
<View className="px-4 py-3.5">
<View className="flex-row items-center mb-2">
<LayoutGrid size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
<Text
className={`ml-3 text-sm ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_500Medium' }}
>
{t('widget.period')}
</Text>
</View>
<View className="flex-row flex-wrap gap-2">
{[
{ value: 1, label: t('widget.periodWeek', { count: 1 }) },
{ value: 2, label: t('widget.periodWeek', { count: 2 }) },
{ value: 4, label: t('widget.periodWeek', { count: 4 }) },
{ value: 0, label: t('widget.periodAll') },
].map((opt) => {
const isActive = widgetPeriodWeeks === opt.value;
return (
<Pressable
key={opt.value}
onPress={() => {
setWidgetPeriodWeeks(opt.value);
syncWidgetData();
}}
className={`rounded-full px-3 py-1.5 ${isActive ? 'bg-bleu' : isDark ? 'bg-[#3A3A3A]' : 'bg-[#E5E7EB]'}`}
>
<Text
className={`text-sm ${isActive ? 'text-white' : isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: isActive ? 'Inter_600SemiBold' : 'Inter_400Regular' }}
>
{opt.label}
</Text>
</Pressable>
);
})}
</View>
</View>
</View>
</View>
{/* Tags Section */}
<View className="px-4 pt-6">
<View className="mb-3 flex-row items-center justify-between">
@ -405,8 +312,8 @@ export default function SettingsScreen() {
{/* Tag Create/Edit Modal */}
<Modal visible={showTagModal} transparent animationType="fade">
<KeyboardAvoidingView
behavior="padding"
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
>
<Pressable onPress={() => setShowTagModal(false)} className="flex-1 justify-center bg-black/40 px-6">
<Pressable
@ -493,44 +400,13 @@ export default function SettingsScreen() {
>
{t('settings.about')}
</Text>
<View className={`overflow-hidden rounded-xl ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`}>
<View className={`px-4 py-3.5 border-b ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}>
<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>
<Pressable
onPress={handleCheckUpdate}
disabled={checkingUpdate}
className={`flex-row items-center border-b px-4 py-3.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
>
{checkingUpdate ? (
<ActivityIndicator size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
) : (
<RefreshCw size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
)}
<Text
className={`ml-3 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{t('settings.checkUpdate')}
</Text>
</Pressable>
<Pressable
onPress={() => Linking.openURL('mailto:lacompagniemaximus@protonmail.com')}
className="flex-row items-center px-4 py-3.5"
>
<Mail size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
<Text
className={`ml-3 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{t('settings.contactOrReport')}
</Text>
</Pressable>
<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>
</ScrollView>

View file

@ -6,14 +6,12 @@ import { useFonts, Inter_400Regular, Inter_500Medium, Inter_600SemiBold, Inter_7
import * as SplashScreen from 'expo-splash-screen';
import { useMigrations } from 'drizzle-orm/expo-sqlite/migrator';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { KeyboardProvider } from 'react-native-keyboard-controller';
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 { initNotifications } from '@/src/services/notifications';
import { syncWidgetData } from '@/src/services/widgetSync';
import '@/src/i18n';
import '@/src/global.css';
@ -68,7 +66,6 @@ export default function RootLayout() {
if (fontsLoaded && migrationsReady) {
ensureInbox().then(async () => {
await initNotifications();
syncWidgetData().catch(() => {});
SplashScreen.hideAsync();
});
}
@ -80,25 +77,23 @@ export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<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.Screen
name="list/[id]"
options={{ headerShown: false }}
/>
</Stack>
</ThemeProvider>
</KeyboardProvider>
<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.Screen
name="list/[id]"
options={{ headerShown: false }}
/>
</Stack>
</ThemeProvider>
</GestureHandlerRootView>
);
}

View file

@ -4,11 +4,11 @@ import {
Text,
TextInput,
Pressable,
ScrollView,
useColorScheme,
Alert,
Platform,
} from 'react-native';
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
import { useRouter, useLocalSearchParams } from 'expo-router';
import {
ArrowLeft, Plus, Trash2, Calendar, X, Repeat, Download,
@ -25,7 +25,6 @@ import { colors } from '@/src/theme/colors';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { isValidUUID } from '@/src/lib/validation';
import { getPriorityOptions } from '@/src/lib/priority';
import { goBack } from '@/src/lib/navigation';
import { RECURRENCE_OPTIONS } from '@/src/lib/recurrence';
import {
getTaskById,
@ -80,7 +79,6 @@ export default function TaskDetailScreen() {
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [lists, setLists] = useState<{ id: string; name: string; color: string | null; icon: string | null; isInbox: boolean }[]>([]);
const [selectedListId, setSelectedListId] = useState<string>('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!isValidUUID(id)) {
@ -114,24 +112,17 @@ export default function TaskDetailScreen() {
};
const handleSave = async () => {
if (saving) return;
if (!task || !title.trim()) return;
setSaving(true);
try {
await updateTask(task.id, {
title: title.trim(),
notes: notes.trim() || undefined,
priority,
dueDate,
recurrence,
listId: selectedListId,
});
await setTagsForTask(task.id, selectedTagIds);
goBack(router);
} catch {
// Save failed — stay on screen so user can retry
setSaving(false);
}
await updateTask(task.id, {
title: title.trim(),
notes: notes.trim() || undefined,
priority,
dueDate,
recurrence,
listId: selectedListId,
});
await setTagsForTask(task.id, selectedTagIds);
router.back();
};
const handleDelete = () => {
@ -143,7 +134,7 @@ export default function TaskDetailScreen() {
onPress: async () => {
await deleteTask(id!);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
goBack(router);
router.back();
},
},
]);
@ -187,7 +178,7 @@ export default function TaskDetailScreen() {
<View
className={`flex-row items-center justify-between border-b px-4 pb-3 pt-14 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
>
<Pressable onPress={() => goBack(router)} className="p-2.5" hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Pressable onPress={() => router.back()} className="p-1">
<ArrowLeft size={24} color={isDark ? '#F5F5F5' : '#1A1A1A'} />
</Pressable>
<View className="flex-row items-center">
@ -197,22 +188,21 @@ export default function TaskDetailScreen() {
[{ id: id!, title, notes: notes || null, dueDate, priority, completed: task.completed, recurrence }],
title
)}
className="mr-3 p-2.5"
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
className="mr-3 p-1"
>
<Download size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>
)}
<Pressable onPress={handleDelete} className="mr-3 p-2.5" hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Pressable onPress={handleDelete} className="mr-3 p-1">
<Trash2 size={20} color={colors.terracotta.DEFAULT} />
</Pressable>
<Pressable onPress={handleSave} disabled={saving} className={`rounded-lg bg-bleu px-4 py-2 ${saving ? 'opacity-50' : ''}`}>
<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>
<KeyboardAwareScrollView className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled" bottomOffset={20}>
<ScrollView className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled">
{/* Title */}
<TextInput
value={title}
@ -408,8 +398,8 @@ export default function TaskDetailScreen() {
/>
</View>
<View style={{ height: 32 }} />
</KeyboardAwareScrollView>
<View className="h-24" />
</ScrollView>
</View>
);
}

View file

@ -4,13 +4,13 @@ import {
Text,
TextInput,
Pressable,
ScrollView,
useColorScheme,
Platform,
} from 'react-native';
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
import { useRouter, useLocalSearchParams } from 'expo-router';
import {
X, Calendar, Repeat, Plus,
X, Calendar, Repeat,
List, ShoppingCart, Briefcase, Home, Heart, Star, BookOpen,
GraduationCap, Dumbbell, Utensils, Plane, Music, Code, Wrench,
Gift, Camera, Palette, Dog, Leaf, Zap,
@ -27,7 +27,6 @@ import { getInboxId, getAllLists } from '@/src/db/repository/lists';
import { getAllTags, setTagsForTask } from '@/src/db/repository/tags';
import { getPriorityOptions } from '@/src/lib/priority';
import { RECURRENCE_OPTIONS } from '@/src/lib/recurrence';
import { goBack } from '@/src/lib/navigation';
import TagChip from '@/src/components/task/TagChip';
const ICON_MAP: Record<string, LucideIcon> = {
@ -56,9 +55,6 @@ export default function NewTaskScreen() {
const [recurrence, setRecurrence] = useState<string | null>(null);
const [availableTags, setAvailableTags] = useState<{ id: string; name: string; color: string }[]>([]);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [pendingSubtasks, setPendingSubtasks] = useState<string[]>([]);
const [newSubtask, setNewSubtask] = useState('');
useEffect(() => {
getAllLists().then(setLists);
@ -66,9 +62,7 @@ export default function NewTaskScreen() {
}, []);
const handleSave = async () => {
if (saving) return;
if (!title.trim()) return;
setSaving(true);
try {
const taskId = await createTask({
title: title.trim(),
@ -81,13 +75,9 @@ export default function NewTaskScreen() {
if (selectedTagIds.length > 0) {
await setTagsForTask(taskId, selectedTagIds);
}
for (const sub of pendingSubtasks) {
await createTask({ title: sub, listId: selectedListId, parentId: taskId });
}
goBack(router);
router.back();
} catch {
// FK constraint or other DB error — fallback to inbox
setSaving(false);
setSelectedListId(getInboxId());
}
};
@ -97,16 +87,6 @@ export default function NewTaskScreen() {
if (date) setDueDate(date);
};
const handleAddPendingSubtask = () => {
if (!newSubtask.trim()) return;
setPendingSubtasks((prev) => [...prev, newSubtask.trim()]);
setNewSubtask('');
};
const handleRemovePendingSubtask = (index: number) => {
setPendingSubtasks((prev) => prev.filter((_, i) => i !== index));
};
const toggleTag = (tagId: string) => {
setSelectedTagIds((prev) =>
prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId]
@ -121,7 +101,7 @@ export default function NewTaskScreen() {
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
>
<Pressable onPress={() => goBack(router)} className="p-2.5" hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Pressable onPress={() => router.back()} className="p-1">
<X size={24} color={isDark ? '#F5F5F5' : '#1A1A1A'} />
</Pressable>
<Text
@ -130,14 +110,14 @@ export default function NewTaskScreen() {
>
{t('task.newTask')}
</Text>
<Pressable onPress={handleSave} disabled={saving} className={`rounded-lg bg-bleu px-4 py-2 ${saving ? 'opacity-50' : ''}`}>
<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>
<KeyboardAwareScrollView className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled" bottomOffset={20}>
<ScrollView className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled">
{/* Title */}
<TextInput
autoFocus
@ -334,50 +314,8 @@ export default function NewTaskScreen() {
</>
)}
{/* 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>
{pendingSubtasks.map((sub, index) => (
<View
key={index}
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: colors.priority.none, backgroundColor: 'transparent' }}
/>
<Text
className={`flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{sub}
</Text>
<Pressable onPress={() => handleRemovePendingSubtask(index)} className="p-1">
<X size={16} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>
</View>
))}
{/* Add subtask */}
<View className="mt-2 flex-row items-center">
<Plus size={18} color={colors.bleu.DEFAULT} />
<TextInput
value={newSubtask}
onChangeText={setNewSubtask}
onSubmitEditing={handleAddPendingSubtask}
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 style={{ height: 32 }} />
</KeyboardAwareScrollView>
<View className="h-24" />
</ScrollView>
</View>
);
}

475
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "simpl-liste",
"version": "1.3.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simpl-liste",
"version": "1.3.0",
"version": "1.0.0",
"dependencies": {
"@expo-google-fonts/inter": "^0.4.2",
"@expo/ngrok": "^4.1.3",
@ -43,7 +43,6 @@
"react-native-android-widget": "^0.20.1",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "1.18.5",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
@ -1594,6 +1593,418 @@
"source-map-support": "^0.5.21"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
"integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz",
"integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz",
"integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz",
"integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz",
"integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz",
"integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz",
"integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz",
"integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz",
"integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz",
"integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz",
"integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz",
"integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz",
"integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz",
"integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz",
"integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz",
"integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz",
"integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz",
"integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz",
"integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz",
"integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz",
"integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz",
"integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild-kit/core-utils/node_modules/esbuild": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
"integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/android-arm": "0.18.20",
"@esbuild/android-arm64": "0.18.20",
"@esbuild/android-x64": "0.18.20",
"@esbuild/darwin-arm64": "0.18.20",
"@esbuild/darwin-x64": "0.18.20",
"@esbuild/freebsd-arm64": "0.18.20",
"@esbuild/freebsd-x64": "0.18.20",
"@esbuild/linux-arm": "0.18.20",
"@esbuild/linux-arm64": "0.18.20",
"@esbuild/linux-ia32": "0.18.20",
"@esbuild/linux-loong64": "0.18.20",
"@esbuild/linux-mips64el": "0.18.20",
"@esbuild/linux-ppc64": "0.18.20",
"@esbuild/linux-riscv64": "0.18.20",
"@esbuild/linux-s390x": "0.18.20",
"@esbuild/linux-x64": "0.18.20",
"@esbuild/netbsd-x64": "0.18.20",
"@esbuild/openbsd-x64": "0.18.20",
"@esbuild/sunos-x64": "0.18.20",
"@esbuild/win32-arm64": "0.18.20",
"@esbuild/win32-ia32": "0.18.20",
"@esbuild/win32-x64": "0.18.20"
}
},
"node_modules/@esbuild-kit/esm-loader": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz",
@ -3306,9 +3717,9 @@
}
},
"node_modules/@react-native/codegen/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@ -6726,9 +7137,9 @@
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
"integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
@ -8485,12 +8896,12 @@
}
},
"node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@ -9984,20 +10395,6 @@
"react-native": "*"
}
},
"node_modules/react-native-keyboard-controller": {
"version": "1.18.5",
"resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.18.5.tgz",
"integrity": "sha512-wbYN6Tcu3G5a05dhRYBgjgd74KqoYWuUmroLpigRg9cXy5uYo7prTMIvMgvLtARQtUF7BOtFggUnzgoBOgk0TQ==",
"license": "MIT",
"dependencies": {
"react-native-is-edge-to-edge": "^1.2.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*",
"react-native-reanimated": ">=3.0.0"
}
},
"node_modules/react-native-reanimated": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz",
@ -10198,9 +10595,9 @@
}
},
"node_modules/react-native/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@ -10597,9 +10994,9 @@
}
},
"node_modules/rimraf/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@ -11204,9 +11601,9 @@
}
},
"node_modules/tar": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz",
"integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==",
"version": "7.5.9",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
"integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
@ -11323,9 +11720,9 @@
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"

View file

@ -1,7 +1,7 @@
{
"name": "simpl-liste",
"main": "index.js",
"version": "1.3.0",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"android": "expo start --android",
@ -44,7 +44,6 @@
"react-native-android-widget": "^0.20.1",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "1.18.5",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
@ -60,8 +59,5 @@
"tailwindcss": "^3.4.17",
"typescript": "~5.9.2"
},
"overrides": {
"esbuild": "^0.25.0"
},
"private": true
}

View file

@ -85,19 +85,18 @@ export async function getTasksByList(listId: string, filters?: TaskFilters) {
function getOrderClauses(sortBy: SortBy, sortOrder: SortOrder) {
const dir = sortOrder === 'asc' ? asc : desc;
// Always sort completed tasks to the bottom, then apply the requested sort
switch (sortBy) {
case 'priority':
return [asc(tasks.completed), dir(tasks.priority), asc(tasks.position)];
return [dir(tasks.priority), asc(tasks.position)];
case 'dueDate':
return [asc(tasks.completed), dir(tasks.dueDate), asc(tasks.position)];
return [dir(tasks.dueDate), asc(tasks.position)];
case 'title':
return [asc(tasks.completed), dir(tasks.title)];
return [dir(tasks.title)];
case 'createdAt':
return [asc(tasks.completed), dir(tasks.createdAt)];
return [dir(tasks.createdAt)];
case 'position':
default:
return [asc(tasks.completed), asc(tasks.position), desc(tasks.createdAt)];
return [asc(tasks.position), desc(tasks.createdAt)];
}
}
@ -106,7 +105,7 @@ export async function getSubtasks(parentId: string) {
.select()
.from(tasks)
.where(eq(tasks.parentId, parentId))
.orderBy(asc(tasks.completed), asc(tasks.position));
.orderBy(asc(tasks.position));
}
export async function getTaskById(id: string) {

View file

@ -96,13 +96,7 @@
"light": "Light",
"system": "System",
"about": "About",
"version": "Version",
"contactOrReport": "Contact us or report a bug",
"checkUpdate": "Check for updates",
"upToDate": "You're up to date!",
"newVersion": "New version available: {{version}}",
"download": "Download",
"updateError": "Unable to check for updates"
"version": "Version"
},
"notifications": {
"title": "Notifications",
@ -138,10 +132,6 @@
"overdue": "Overdue",
"today": "Today",
"tomorrow": "Tomorrow",
"noDate": "No date",
"period": "Display period",
"periodWeek_one": "{{count}} week",
"periodWeek_other": "{{count}} weeks",
"periodAll": "All"
"noDate": "No date"
}
}

View file

@ -96,13 +96,7 @@
"light": "Clair",
"system": "Système",
"about": "À propos",
"version": "Version",
"contactOrReport": "Nous joindre ou signaler un bogue",
"checkUpdate": "Vérifier les mises à jour",
"upToDate": "Vous êtes à jour !",
"newVersion": "Nouvelle version disponible : {{version}}",
"download": "Télécharger",
"updateError": "Impossible de vérifier les mises à jour"
"version": "Version"
},
"notifications": {
"title": "Notifications",
@ -138,10 +132,6 @@
"overdue": "En retard",
"today": "Aujourd'hui",
"tomorrow": "Demain",
"noDate": "Sans date",
"period": "Période affichée",
"periodWeek_one": "{{count}} semaine",
"periodWeek_other": "{{count}} semaines",
"periodAll": "Toutes"
"noDate": "Sans date"
}
}

View file

@ -1,13 +0,0 @@
import type { Router } from 'expo-router';
/**
* Navigate back if possible, otherwise replace with root.
* Shared between task screens to avoid duplication.
*/
export const goBack = (router: Router) => {
if (router.canGoBack()) {
router.back();
} else {
router.replace('/');
}
};

View file

@ -1,20 +1,13 @@
import { Platform, Appearance } from 'react-native';
import { Platform } from 'react-native';
import { requestWidgetUpdate } from 'react-native-android-widget';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { db } from '../db/client';
import { tasks, lists } from '../db/schema';
import { eq, and, isNull, gte, lte, lt, asc, sql } from 'drizzle-orm';
import { eq, and, isNull, gte, lte, lt, asc } from 'drizzle-orm';
import { startOfDay, endOfDay, addWeeks } from 'date-fns';
import { TaskListWidget } from '../widgets/TaskListWidget';
export const WIDGET_DATA_KEY = 'widget:tasks';
export const WIDGET_DARK_KEY = 'widget:isDark';
export interface WidgetSubtask {
id: string;
title: string;
completed: boolean;
}
export interface WidgetTask {
id: string;
@ -23,9 +16,6 @@ export interface WidgetTask {
dueDate: string | null;
completed: boolean;
listColor: string | null;
subtaskCount: number;
subtaskDoneCount: number;
subtasks: WidgetSubtask[];
}
export async function syncWidgetData(): Promise<void> {
@ -34,20 +24,7 @@ export async function syncWidgetData(): Promise<void> {
try {
const now = new Date();
const todayStart = startOfDay(now);
// Read widget period setting from AsyncStorage (0 = all, N = N weeks ahead)
// Coupled with useSettingsStore.ts — key 'simpl-liste-settings', path state.widgetPeriodWeeks
let widgetPeriodWeeks = 0;
try {
const settingsRaw = await AsyncStorage.getItem('simpl-liste-settings');
if (settingsRaw) {
const settings = JSON.parse(settingsRaw);
const stored = settings?.state?.widgetPeriodWeeks;
if (typeof stored === 'number') widgetPeriodWeeks = stored;
}
} catch {
// Default to all tasks
}
const twoWeeksEnd = endOfDay(addWeeks(now, 2));
const selectFields = {
id: tasks.id,
@ -57,24 +34,21 @@ export async function syncWidgetData(): Promise<void> {
completed: tasks.completed,
position: tasks.position,
listColor: lists.color,
subtaskCount: sql<number>`(SELECT COUNT(*) FROM tasks AS sub WHERE sub.parent_id = ${tasks.id})`.as('subtask_count'),
subtaskDoneCount: sql<number>`(SELECT COUNT(*) FROM tasks AS sub WHERE sub.parent_id = ${tasks.id} AND sub.completed = 1)`.as('subtask_done_count'),
};
// Fetch upcoming tasks (filtered by period setting, 0 = all future tasks)
const upcomingConditions = [
eq(tasks.completed, false),
isNull(tasks.parentId),
gte(tasks.dueDate, todayStart),
];
if (widgetPeriodWeeks > 0) {
upcomingConditions.push(lte(tasks.dueDate, endOfDay(addWeeks(now, widgetPeriodWeeks))));
}
// Fetch tasks with due date in the next 2 weeks
const upcomingTasks = await db
.select(selectFields)
.from(tasks)
.leftJoin(lists, eq(tasks.listId, lists.id))
.where(and(...upcomingConditions))
.where(
and(
eq(tasks.completed, false),
isNull(tasks.parentId),
gte(tasks.dueDate, todayStart),
lte(tasks.dueDate, twoWeeksEnd)
)
)
.orderBy(asc(tasks.dueDate));
// Fetch overdue tasks
@ -103,7 +77,7 @@ export async function syncWidgetData(): Promise<void> {
isNull(tasks.dueDate)
)
)
.orderBy(asc(tasks.completed), asc(tasks.position));
.orderBy(asc(tasks.position));
const toWidgetTask = (t: typeof upcomingTasks[number]): WidgetTask => ({
id: t.id,
@ -112,9 +86,6 @@ export async function syncWidgetData(): Promise<void> {
dueDate: t.dueDate ? new Date(t.dueDate).toISOString() : null,
completed: t.completed,
listColor: t.listColor,
subtaskCount: t.subtaskCount ?? 0,
subtaskDoneCount: t.subtaskDoneCount ?? 0,
subtasks: [],
});
// Combine: overdue first, then upcoming, then no date
@ -124,49 +95,7 @@ export async function syncWidgetData(): Promise<void> {
...noDateTasks.map(toWidgetTask),
];
// Fetch subtasks for tasks that have them
for (const task of allTasks) {
if (task.subtaskCount > 0) {
const subs = await db
.select({ id: tasks.id, title: tasks.title, completed: tasks.completed })
.from(tasks)
.where(eq(tasks.parentId, task.id))
.orderBy(asc(tasks.completed), asc(tasks.position));
task.subtasks = subs;
}
}
// Determine dark mode from settings
let isDark = false;
try {
const settingsRaw = await AsyncStorage.getItem('simpl-liste-settings');
if (settingsRaw) {
const settings = JSON.parse(settingsRaw);
const theme: string = settings?.state?.theme ?? 'system';
if (theme === 'dark') {
isDark = true;
} else if (theme === 'system') {
isDark = Appearance.getColorScheme() === 'dark';
}
}
} catch {
// Default to light
}
await AsyncStorage.setItem(WIDGET_DATA_KEY, JSON.stringify(allTasks));
await AsyncStorage.setItem(WIDGET_DARK_KEY, JSON.stringify(isDark));
// Read expanded state
let expandedTaskIds: string[] = [];
try {
const expandedRaw = await AsyncStorage.getItem('widget:expandedTaskIds');
if (expandedRaw) {
const parsed = JSON.parse(expandedRaw);
if (Array.isArray(parsed)) expandedTaskIds = parsed;
}
} catch {
// Default to none expanded
}
// Request widget update for all 3 sizes
const widgetNames = ['SimplListeSmall', 'SimplListeMedium', 'SimplListeLarge'];
@ -175,7 +104,7 @@ export async function syncWidgetData(): Promise<void> {
await requestWidgetUpdate({
widgetName,
renderWidget: (props) =>
TaskListWidget({ ...props, widgetName, tasks: allTasks, isDark, expandedTaskIds }),
TaskListWidget({ ...props, widgetName, tasks: allTasks }),
widgetNotFound: () => {},
});
} catch {

View file

@ -10,13 +10,11 @@ interface SettingsState {
notificationsEnabled: boolean;
reminderOffset: number; // hours before due date (0 = at time)
calendarSyncEnabled: boolean;
widgetPeriodWeeks: number; // 0 = all tasks, otherwise number of weeks ahead
setTheme: (theme: ThemeMode) => void;
setLocale: (locale: 'fr' | 'en') => void;
setNotificationsEnabled: (enabled: boolean) => void;
setReminderOffset: (offset: number) => void;
setCalendarSyncEnabled: (enabled: boolean) => void;
setWidgetPeriodWeeks: (weeks: number) => void;
}
export const useSettingsStore = create<SettingsState>()(
@ -27,13 +25,11 @@ export const useSettingsStore = create<SettingsState>()(
notificationsEnabled: true,
reminderOffset: 0,
calendarSyncEnabled: false,
widgetPeriodWeeks: 0,
setTheme: (theme) => set({ theme }),
setLocale: (locale) => set({ locale }),
setNotificationsEnabled: (notificationsEnabled) => set({ notificationsEnabled }),
setReminderOffset: (reminderOffset) => set({ reminderOffset }),
setCalendarSyncEnabled: (calendarSyncEnabled) => set({ calendarSyncEnabled }),
setWidgetPeriodWeeks: (widgetPeriodWeeks) => set({ widgetPeriodWeeks }),
}),
{
name: 'simpl-liste-settings',

View file

@ -1,10 +1,10 @@
import React from 'react';
import { FlexWidget, ListWidget, TextWidget } from 'react-native-android-widget';
import { FlexWidget, TextWidget } from 'react-native-android-widget';
import type { WidgetInfo } from 'react-native-android-widget';
type HexColor = `#${string}`;
type ColorProp = HexColor;
import type { WidgetTask, WidgetSubtask } from '../services/widgetSync';
import type { WidgetTask } from '../services/widgetSync';
import {
isToday,
isTomorrow,
@ -17,26 +17,13 @@ import { fr } from 'date-fns/locale';
const FONT_REGULAR = 'Inter_400Regular';
const FONT_SEMIBOLD = 'Inter_600SemiBold';
const LIGHT_COLORS = {
bg: '#FFF8F0' as const,
text: '#1A1A1A' as const,
textSecondary: '#6B6B6B' as const,
border: '#E5E7EB' as const,
surface: '#FFFFFF' as const,
checkboxUnchecked: '#D1D5DB' as const,
};
const DARK_COLORS = {
bg: '#1A1A1A' as const,
text: '#F5F5F5' as const,
textSecondary: '#A0A0A0' as const,
border: '#3A3A3A' as const,
surface: '#2A2A2A' as const,
checkboxUnchecked: '#555555' as const,
};
const BG_COLOR = '#FFF8F0' as const;
const TEXT_COLOR = '#1A1A1A' as const;
const TEXT_SECONDARY = '#6B6B6B' as const;
const BORDER_COLOR = '#E5E7EB' as const;
const OVERDUE_COLOR = '#C17767' as const;
const TODAY_COLOR = '#4A90A4' as const;
const CHECKBOX_UNCHECKED = '#D1D5DB' as const;
const DEFAULT_LIST_COLOR = '#4A90A4' as const;
const PRIORITY_COLORS = {
@ -45,17 +32,13 @@ const PRIORITY_COLORS = {
1: '#8BA889' as const,
} as const;
function getColors(isDark: boolean) {
return isDark ? DARK_COLORS : LIGHT_COLORS;
}
function getPriorityDotColor(priority: number): ColorProp | null {
return PRIORITY_COLORS[priority as keyof typeof PRIORITY_COLORS] ?? null;
}
function getDateLabel(dueDate: string | null, c: ReturnType<typeof getColors>): { text: string; color: ColorProp } {
function getDateLabel(dueDate: string | null): { text: string; color: ColorProp } {
if (!dueDate) {
return { text: 'Sans date', color: c.textSecondary };
return { text: 'Sans date', color: TEXT_SECONDARY };
}
const date = new Date(dueDate);
@ -68,256 +51,120 @@ function getDateLabel(dueDate: string | null, c: ReturnType<typeof getColors>):
return { text: "Aujourd'hui", color: TODAY_COLOR };
}
if (isTomorrow(date)) {
return { text: 'Demain', color: c.text };
return { text: 'Demain', color: TEXT_COLOR };
}
return {
text: format(date, 'EEE d MMM', { locale: fr }),
color: c.textSecondary,
color: TEXT_SECONDARY,
};
}
function SubtaskItemRow({
subtask,
parentId,
isDark,
}: {
subtask: WidgetSubtask;
parentId: string;
isDark: boolean;
}) {
const c = getColors(isDark);
interface TaskListWidgetProps extends WidgetInfo {
widgetName: string;
tasks?: WidgetTask[];
}
function TaskItemRow({ task }: { task: WidgetTask }) {
const dateInfo = getDateLabel(task.dueDate);
const priorityColor = getPriorityDotColor(task.priority);
return (
<FlexWidget
style={{
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 44,
paddingRight: 12,
paddingVertical: 5,
paddingHorizontal: 12,
paddingVertical: 8,
width: 'match_parent',
backgroundColor: isDark ? '#232323' as ColorProp : '#FFF4E8' as ColorProp,
borderBottomWidth: 1,
borderColor: BORDER_COLOR,
}}
clickAction="OPEN_URI"
clickActionData={{ uri: `simplliste:///task/${task.id}` }}
>
{/* Subtask checkbox */}
{/* List color indicator */}
<FlexWidget
style={{
width: 18,
height: 18,
borderRadius: 9,
borderWidth: 2,
borderColor: subtask.completed ? TODAY_COLOR : c.checkboxUnchecked,
backgroundColor: subtask.completed ? TODAY_COLOR : '#00000000' as ColorProp,
width: 4,
height: 28,
borderRadius: 2,
backgroundColor: (task.listColor ?? DEFAULT_LIST_COLOR) as ColorProp,
marginRight: 8,
}}
/>
{/* Checkbox */}
<FlexWidget
style={{
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 2,
borderColor: CHECKBOX_UNCHECKED,
marginRight: 10,
alignItems: 'center',
justifyContent: 'center',
}}
clickAction="TOGGLE_SUBTASK"
clickActionData={{ subtaskId: subtask.id, parentId }}
clickAction="TOGGLE_COMPLETE"
clickActionData={{ taskId: task.id }}
/>
{/* Subtask title */}
<FlexWidget style={{ flex: 1 }}>
<TextWidget
text={subtask.title}
maxLines={1}
truncate="END"
style={{
fontSize: 12,
fontFamily: FONT_REGULAR,
color: subtask.completed ? c.textSecondary : c.text,
}}
/>
</FlexWidget>
</FlexWidget>
);
}
interface TaskListWidgetProps extends WidgetInfo {
widgetName: string;
tasks?: WidgetTask[];
isDark?: boolean;
expandedTaskIds?: string[];
}
function TaskItemRow({
task,
isDark,
isExpanded,
}: {
task: WidgetTask;
isDark: boolean;
isExpanded: boolean;
}) {
const c = getColors(isDark);
const dateInfo = getDateLabel(task.dueDate, c);
const priorityColor = getPriorityDotColor(task.priority);
const hasSubtasks = (task.subtaskCount ?? 0) > 0;
return (
<FlexWidget
style={{
flexDirection: 'column',
width: 'match_parent',
}}
>
{/* Main task row */}
{/* Priority dot + title */}
<FlexWidget
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 8,
width: 'match_parent',
borderBottomWidth: isExpanded ? 0 : 1,
borderColor: c.border,
}}
clickAction="OPEN_URI"
clickActionData={{ uri: `simplliste:///task/${task.id}` }}
>
{/* List color indicator */}
<FlexWidget
style={{
width: 4,
height: 28,
borderRadius: 2,
backgroundColor: (task.listColor ?? DEFAULT_LIST_COLOR) as ColorProp,
marginRight: 8,
}}
/>
{/* Checkbox */}
<FlexWidget
style={{
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 2,
borderColor: c.checkboxUnchecked,
marginRight: 10,
alignItems: 'center',
justifyContent: 'center',
}}
clickAction="TOGGLE_COMPLETE"
clickActionData={{ taskId: task.id }}
/>
{/* Priority dot + title + subtask indicator */}
<FlexWidget
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'center',
}}
>
{priorityColor != null ? (
<FlexWidget
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: priorityColor,
marginRight: 6,
}}
/>
) : null}
<FlexWidget style={{ flex: 1, flexDirection: 'column' }}>
<TextWidget
text={task.title}
maxLines={1}
truncate="END"
style={{
fontSize: 14,
fontFamily: FONT_REGULAR,
color: c.text,
}}
/>
{hasSubtasks ? (
<TextWidget
text={`${task.subtaskDoneCount ?? 0}/${task.subtaskCount}`}
style={{
fontSize: 11,
fontFamily: FONT_REGULAR,
color: task.subtaskDoneCount === task.subtaskCount ? TODAY_COLOR : c.textSecondary,
marginTop: 1,
}}
/>
) : null}
</FlexWidget>
</FlexWidget>
{/* Expand/collapse button */}
{hasSubtasks ? (
{priorityColor != null ? (
<FlexWidget
style={{
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: isDark ? '#2A2A2A' as ColorProp : '#F0E8DC' as ColorProp,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 6,
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: priorityColor,
marginRight: 6,
}}
clickAction="TOGGLE_EXPAND"
clickActionData={{ taskId: task.id }}
>
<TextWidget
text={isExpanded ? '▾' : '▸'}
style={{
fontSize: 18,
fontFamily: FONT_SEMIBOLD,
color: TODAY_COLOR,
}}
/>
</FlexWidget>
/>
) : null}
{/* Date label */}
<TextWidget
text={dateInfo.text}
style={{
fontSize: 11,
fontFamily: FONT_REGULAR,
color: dateInfo.color,
marginLeft: 8,
}}
/>
<FlexWidget style={{ flex: 1 }}>
<TextWidget
text={task.title}
maxLines={1}
truncate="END"
style={{
fontSize: 14,
fontFamily: FONT_REGULAR,
color: TEXT_COLOR,
}}
/>
</FlexWidget>
</FlexWidget>
{/* Expanded subtasks */}
{isExpanded && task.subtasks?.length ? (
<FlexWidget
style={{
flexDirection: 'column',
width: 'match_parent',
borderBottomWidth: 1,
borderColor: c.border,
}}
>
{task.subtasks.map((sub) => (
<SubtaskItemRow
key={sub.id}
subtask={sub}
parentId={task.id}
isDark={isDark}
/>
))}
</FlexWidget>
) : null}
{/* Date label */}
<TextWidget
text={dateInfo.text}
style={{
fontSize: 11,
fontFamily: FONT_REGULAR,
color: dateInfo.color,
marginLeft: 8,
}}
/>
</FlexWidget>
);
}
function SmallWidget({ tasks, isDark }: { tasks: WidgetTask[]; isDark: boolean }) {
const c = getColors(isDark);
function SmallWidget({ tasks }: { tasks: WidgetTask[] }) {
return (
<FlexWidget
style={{
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: c.bg,
backgroundColor: BG_COLOR,
borderRadius: 16,
width: 'match_parent',
height: 'match_parent',
@ -330,7 +177,7 @@ function SmallWidget({ tasks, isDark }: { tasks: WidgetTask[]; isDark: boolean }
style={{
fontSize: 14,
fontFamily: FONT_SEMIBOLD,
color: c.text,
color: TEXT_COLOR,
marginBottom: 4,
}}
/>
@ -348,7 +195,7 @@ function SmallWidget({ tasks, isDark }: { tasks: WidgetTask[]; isDark: boolean }
style={{
fontSize: 12,
fontFamily: FONT_REGULAR,
color: c.textSecondary,
color: TEXT_SECONDARY,
marginBottom: 8,
}}
/>
@ -380,20 +227,18 @@ function SmallWidget({ tasks, isDark }: { tasks: WidgetTask[]; isDark: boolean }
function ListWidgetContent({
tasks,
isDark,
expandedTaskIds,
maxItems,
}: {
tasks: WidgetTask[];
isDark: boolean;
expandedTaskIds: Set<string>;
maxItems: number;
}) {
const c = getColors(isDark);
const displayTasks = tasks.slice(0, maxItems);
return (
<FlexWidget
style={{
flexDirection: 'column',
backgroundColor: c.bg,
backgroundColor: BG_COLOR,
borderRadius: 16,
width: 'match_parent',
height: 'match_parent',
@ -410,32 +255,31 @@ function ListWidgetContent({
paddingVertical: 10,
width: 'match_parent',
borderBottomWidth: 1,
borderColor: c.border,
borderColor: BORDER_COLOR,
}}
clickAction="OPEN_APP"
>
<TextWidget
text="Simpl-Liste"
style={{
fontSize: 16,
fontFamily: FONT_SEMIBOLD,
color: TEXT_COLOR,
}}
/>
<FlexWidget
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'center',
}}
clickAction="OPEN_APP"
>
<TextWidget
text="Simpl-Liste"
style={{
fontSize: 16,
fontFamily: FONT_SEMIBOLD,
color: c.text,
}}
/>
<TextWidget
text={`${tasks.length}`}
style={{
fontSize: 13,
fontFamily: FONT_SEMIBOLD,
color: TODAY_COLOR,
marginLeft: 8,
marginRight: 4,
}}
/>
<TextWidget
@ -443,60 +287,25 @@ function ListWidgetContent({
style={{
fontSize: 13,
fontFamily: FONT_REGULAR,
color: c.textSecondary,
}}
/>
</FlexWidget>
{/* Add button */}
<FlexWidget
style={{
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: TODAY_COLOR,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 8,
}}
clickAction="OPEN_URI"
clickActionData={{ uri: 'simplliste:///task/new' }}
>
<TextWidget
text="+"
style={{
fontSize: 18,
fontFamily: FONT_SEMIBOLD,
color: '#FFFFFF',
color: TEXT_SECONDARY,
}}
/>
</FlexWidget>
</FlexWidget>
{/* Task list — cap at 30 items to avoid Android widget memory limits */}
{tasks.length > 0 ? (
<ListWidget
{/* Task list */}
{displayTasks.length > 0 ? (
<FlexWidget
style={{
height: 'match_parent',
flex: 1,
flexDirection: 'column',
width: 'match_parent',
}}
>
{tasks.slice(0, 30).map((task) => (
<FlexWidget
key={task.id}
style={{
flexDirection: 'column',
width: 'match_parent',
}}
>
<TaskItemRow
task={task}
isDark={isDark}
isExpanded={expandedTaskIds.has(task.id)}
/>
</FlexWidget>
{displayTasks.map((task) => (
<TaskItemRow key={task.id} task={task} />
))}
</ListWidget>
</FlexWidget>
) : (
<FlexWidget
style={{
@ -511,12 +320,45 @@ function ListWidgetContent({
style={{
fontSize: 14,
fontFamily: FONT_REGULAR,
color: c.textSecondary,
color: TEXT_SECONDARY,
}}
/>
</FlexWidget>
)}
{/* Add button footer */}
<FlexWidget
style={{
flexDirection: 'row',
justifyContent: 'center',
paddingVertical: 8,
width: 'match_parent',
borderTopWidth: 1,
borderColor: BORDER_COLOR,
}}
clickAction="OPEN_URI"
clickActionData={{ uri: 'simplliste:///task/new' }}
>
<FlexWidget
style={{
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: TODAY_COLOR,
alignItems: 'center',
justifyContent: 'center',
}}
>
<TextWidget
text="+"
style={{
fontSize: 18,
fontFamily: FONT_SEMIBOLD,
color: '#FFFFFF',
}}
/>
</FlexWidget>
</FlexWidget>
</FlexWidget>
);
}
@ -524,18 +366,11 @@ function ListWidgetContent({
export function TaskListWidget(props: TaskListWidgetProps) {
const widgetTasks = props.tasks ?? [];
const widgetName = props.widgetName;
const isDark = props.isDark ?? false;
const expandedTaskIds = new Set(props.expandedTaskIds ?? []);
if (widgetName === 'SimplListeSmall') {
return <SmallWidget tasks={widgetTasks} isDark={isDark} />;
return <SmallWidget tasks={widgetTasks} />;
}
return (
<ListWidgetContent
tasks={widgetTasks}
isDark={isDark}
expandedTaskIds={expandedTaskIds}
/>
);
const maxItems = widgetName === 'SimplListeLarge' ? 8 : 4;
return <ListWidgetContent tasks={widgetTasks} maxItems={maxItems} />;
}

View file

@ -1,11 +1,9 @@
import type { WidgetTaskHandlerProps } from 'react-native-android-widget';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { TaskListWidget } from './TaskListWidget';
import { WIDGET_DATA_KEY, WIDGET_DARK_KEY, type WidgetTask } from '../services/widgetSync';
import { WIDGET_DATA_KEY, type WidgetTask } from '../services/widgetSync';
import { isValidUUID } from '../lib/validation';
const WIDGET_EXPANDED_KEY = 'widget:expandedTaskIds';
function isWidgetTask(item: unknown): item is WidgetTask {
if (typeof item !== 'object' || item === null) return false;
const obj = item as Record<string, unknown>;
@ -15,9 +13,7 @@ function isWidgetTask(item: unknown): item is WidgetTask {
typeof obj.priority === 'number' &&
typeof obj.completed === 'boolean' &&
(obj.dueDate === null || typeof obj.dueDate === 'string') &&
(obj.listColor === null || obj.listColor === undefined || typeof obj.listColor === 'string') &&
(obj.subtaskCount === undefined || typeof obj.subtaskCount === 'number') &&
(obj.subtaskDoneCount === undefined || typeof obj.subtaskDoneCount === 'number')
(obj.listColor === null || obj.listColor === undefined || typeof obj.listColor === 'string')
);
}
@ -27,59 +23,12 @@ async function getWidgetTasks(): Promise<WidgetTask[]> {
if (!data) return [];
const parsed: unknown = JSON.parse(data);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isWidgetTask).map((t) => ({
...t,
subtasks: Array.isArray(t.subtasks) ? t.subtasks : [],
}));
return parsed.filter(isWidgetTask);
} catch {
return [];
}
}
async function getWidgetIsDark(): Promise<boolean> {
try {
const data = await AsyncStorage.getItem(WIDGET_DARK_KEY);
if (!data) return false;
return JSON.parse(data) === true;
} catch {
return false;
}
}
async function getExpandedTaskIds(): Promise<Set<string>> {
try {
const data = await AsyncStorage.getItem(WIDGET_EXPANDED_KEY);
if (!data) return new Set();
const parsed: unknown = JSON.parse(data);
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((id): id is string => typeof id === 'string'));
} catch {
return new Set();
}
}
async function setExpandedTaskIds(ids: Set<string>): Promise<void> {
await AsyncStorage.setItem(WIDGET_EXPANDED_KEY, JSON.stringify([...ids]));
}
function renderWithState(
renderWidget: WidgetTaskHandlerProps['renderWidget'],
widgetInfo: WidgetTaskHandlerProps['widgetInfo'],
tasks: WidgetTask[],
isDark: boolean,
expandedTaskIds: Set<string>,
) {
renderWidget(
TaskListWidget({
...widgetInfo,
widgetName: widgetInfo.widgetName,
tasks,
isDark,
expandedTaskIds: [...expandedTaskIds],
})
);
}
export async function widgetTaskHandler(
props: WidgetTaskHandlerProps
): Promise<void> {
@ -89,12 +38,14 @@ export async function widgetTaskHandler(
case 'WIDGET_ADDED':
case 'WIDGET_UPDATE':
case 'WIDGET_RESIZED': {
const [tasks, isDark, expandedTaskIds] = await Promise.all([
getWidgetTasks(),
getWidgetIsDark(),
getExpandedTaskIds(),
]);
renderWithState(renderWidget, widgetInfo, tasks, isDark, expandedTaskIds);
const tasks = await getWidgetTasks();
renderWidget(
TaskListWidget({
...widgetInfo,
widgetName: widgetInfo.widgetName,
tasks,
})
);
break;
}
@ -106,73 +57,31 @@ export async function widgetTaskHandler(
const taskId = props.clickActionData?.taskId;
if (!isValidUUID(taskId)) break;
const [tasks, isDark, expandedTaskIds] = await Promise.all([
getWidgetTasks(),
getWidgetIsDark(),
getExpandedTaskIds(),
]);
// Update the cached data to remove the completed task immediately
const tasks = await getWidgetTasks();
const updatedTasks = tasks.filter((t) => t.id !== taskId);
await AsyncStorage.setItem(WIDGET_DATA_KEY, JSON.stringify(updatedTasks));
await AsyncStorage.setItem(
WIDGET_DATA_KEY,
JSON.stringify(updatedTasks)
);
renderWithState(renderWidget, widgetInfo, updatedTasks, isDark, expandedTaskIds);
// Re-render the widget with updated data
renderWidget(
TaskListWidget({
...widgetInfo,
widgetName: widgetInfo.widgetName,
tasks: updatedTasks,
})
);
// Toggle in the actual database (async, will re-sync on next app open)
try {
const { toggleComplete } = await import('../db/repository/tasks');
const { toggleComplete } = await import(
'../db/repository/tasks'
);
await toggleComplete(taskId);
} catch {
// DB might not be available in headless mode
}
}
if (props.clickAction === 'TOGGLE_EXPAND') {
const taskId = props.clickActionData?.taskId as string | undefined;
if (!taskId) break;
const [tasks, isDark, expandedTaskIds] = await Promise.all([
getWidgetTasks(),
getWidgetIsDark(),
getExpandedTaskIds(),
]);
if (expandedTaskIds.has(taskId)) {
expandedTaskIds.delete(taskId);
} else {
expandedTaskIds.add(taskId);
}
await setExpandedTaskIds(expandedTaskIds);
renderWithState(renderWidget, widgetInfo, tasks, isDark, expandedTaskIds);
}
if (props.clickAction === 'TOGGLE_SUBTASK') {
const subtaskId = props.clickActionData?.subtaskId as string | undefined;
const parentId = props.clickActionData?.parentId as string | undefined;
if (!isValidUUID(subtaskId) || !parentId) break;
const [tasks, isDark, expandedTaskIds] = await Promise.all([
getWidgetTasks(),
getWidgetIsDark(),
getExpandedTaskIds(),
]);
// Update subtask state in cached data
const parent = tasks.find((t) => t.id === parentId);
if (parent) {
const sub = parent.subtasks?.find((s) => s.id === subtaskId);
if (sub) {
sub.completed = !sub.completed;
parent.subtaskDoneCount = (parent.subtasks ?? []).filter((s) => s.completed).length;
}
}
await AsyncStorage.setItem(WIDGET_DATA_KEY, JSON.stringify(tasks));
renderWithState(renderWidget, widgetInfo, tasks, isDark, expandedTaskIds);
try {
const { toggleComplete } = await import('../db/repository/tasks');
await toggleComplete(subtaskId);
} catch {
// DB might not be available in headless mode
// DB might not be available in headless mode — sync on next app open
}
}
break;