Compare commits
34 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
704ca9f693 | ||
|
|
72ace1db4a | ||
| 3cecf9ba26 | |||
|
|
9a8bb13e97 | ||
|
|
2e13528c6b | ||
|
|
f040ec7902 | ||
| dde33acdf2 | |||
| b5e722c1f0 | |||
| 4c73a16302 | |||
| 2296126ba4 | |||
| d6a69d849b | |||
| 594896a909 | |||
| 8d34ae5267 | |||
| 0462b5a50b | |||
| 2a7b70c65c | |||
| 6c1bd043e6 | |||
| 2d9440b05c | |||
| ce21337042 | |||
|
|
661ac0aa33 | ||
|
|
fa037e9eef | ||
|
|
a8efb82b3a | ||
|
|
bf7c954528 | ||
|
|
64cd7bc896 | ||
|
|
f2fe141737 | ||
|
|
360310e99f | ||
|
|
9835f9ef18 | ||
|
|
fe7bda4747 | ||
|
|
3efb7a1cb0 | ||
|
|
a03085c768 | ||
|
|
55e02e1b3a | ||
|
|
117de533d7 | ||
|
|
2412d368ac | ||
|
|
f61ce64b50 | ||
|
|
72eafbd9d9 |
20 changed files with 785 additions and 672 deletions
7
.claude/rules/i18n.md
Normal file
7
.claude/rules/i18n.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
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.
|
||||
6
.claude/rules/sql-migrations.md
Normal file
6
.claude/rules/sql-migrations.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
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
3
.gitignore
vendored
|
|
@ -31,7 +31,8 @@ yarn-error.*
|
|||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
|
|
|||
74
CLAUDE.md
74
CLAUDE.md
|
|
@ -61,16 +61,21 @@ src/
|
|||
├── lib/
|
||||
│ ├── priority.ts # Helpers couleurs priorité
|
||||
│ ├── recurrence.ts # Types récurrence + calcul prochaine occurrence
|
||||
│ └── uuid.ts # Wrapper expo-crypto randomUUID
|
||||
│ ├── uuid.ts # Wrapper expo-crypto randomUUID
|
||||
│ └── validation.ts # Validation UUID pour deep links
|
||||
├── services/
|
||||
│ ├── calendar.ts # Sync expo-calendar
|
||||
│ ├── icsExport.ts # Export .ics + partage
|
||||
│ └── notifications.ts # Planification expo-notifications
|
||||
│ ├── notifications.ts # Planification expo-notifications
|
||||
│ └── widgetSync.ts # Sync tâches + thème vers widget Android
|
||||
├── stores/
|
||||
│ ├── useSettingsStore.ts # Thème, locale, notifs, calendrier
|
||||
│ └── useTaskStore.ts # État tri/filtre
|
||||
└── theme/
|
||||
└── colors.ts # Palette centralisée (bleu, crème, terracotta)
|
||||
├── 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
|
||||
```
|
||||
|
||||
## Base de données
|
||||
|
|
@ -116,6 +121,63 @@ Puis mettre à jour `src/db/migrations/migrations.js` si nécessaire.
|
|||
|
||||
Couleurs sombres : fond `#1A1A1A`, surface `#2A2A2A`, bordure `#3A3A3A`, texte `#F5F5F5`, secondaire `#A0A0A0`
|
||||
|
||||
## Build
|
||||
## Widget Android
|
||||
|
||||
Profiles EAS dans `eas.json` : dev / preview / production.
|
||||
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é.
|
||||
|
|
|
|||
5
app.json
5
app.json
|
|
@ -2,7 +2,7 @@
|
|||
"expo": {
|
||||
"name": "Simpl-Liste",
|
||||
"slug": "simpl-liste",
|
||||
"version": "1.0.1",
|
||||
"version": "1.3.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "simplliste",
|
||||
|
|
@ -23,7 +23,8 @@
|
|||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#FFF8F0"
|
||||
},
|
||||
"edgeToEdgeEnabled": true
|
||||
"edgeToEdgeEnabled": true,
|
||||
"versionCode": 6
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
View, Text, Pressable, useColorScheme, TextInput, Alert,
|
||||
Modal, KeyboardAvoidingView, Platform, ScrollView,
|
||||
Modal, Platform, ScrollView,
|
||||
} from 'react-native';
|
||||
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
|
||||
import { useRouter } from 'expo-router';
|
||||
import {
|
||||
Plus, ChevronRight, Check, GripVertical,
|
||||
|
|
@ -249,8 +250,8 @@ export default function ListsScreen() {
|
|||
{/* Create/Edit Modal */}
|
||||
<Modal visible={showModal} transparent animationType="fade">
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
className="flex-1"
|
||||
behavior="padding"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Pressable onPress={() => setShowModal(false)} className="flex-1 justify-center bg-black/40 px-6">
|
||||
<Pressable
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { View, Text, Pressable, useColorScheme, TextInput, ScrollView, Alert, Modal, KeyboardAvoidingView, Platform, Switch, Linking, ActivityIndicator } from 'react-native';
|
||||
import { View, Text, Pressable, useColorScheme, TextInput, ScrollView, Alert, Modal, Platform, Switch, Linking, ActivityIndicator } from 'react-native';
|
||||
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Sun, Moon, Smartphone, Plus, Trash2, Pencil, Bell, CalendarDays, Mail, RefreshCw } from 'lucide-react-native';
|
||||
import { Sun, Moon, Smartphone, Plus, Trash2, Pencil, Bell, CalendarDays, LayoutGrid, Mail, RefreshCw } 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';
|
||||
|
|
@ -22,6 +24,7 @@ export default function SettingsScreen() {
|
|||
notificationsEnabled, setNotificationsEnabled,
|
||||
reminderOffset, setReminderOffset,
|
||||
calendarSyncEnabled, setCalendarSyncEnabled,
|
||||
widgetPeriodWeeks, setWidgetPeriodWeeks,
|
||||
} = useSettingsStore();
|
||||
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
|
||||
|
||||
|
|
@ -298,6 +301,56 @@ 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">
|
||||
|
|
@ -352,8 +405,8 @@ export default function SettingsScreen() {
|
|||
{/* Tag Create/Edit Modal */}
|
||||
<Modal visible={showTagModal} transparent animationType="fade">
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
className="flex-1"
|
||||
behavior="padding"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Pressable onPress={() => setShowTagModal(false)} className="flex-1 justify-center bg-black/40 px-6">
|
||||
<Pressable
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ 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';
|
||||
|
||||
|
|
@ -66,6 +68,7 @@ export default function RootLayout() {
|
|||
if (fontsLoaded && migrationsReady) {
|
||||
ensureInbox().then(async () => {
|
||||
await initNotifications();
|
||||
syncWidgetData().catch(() => {});
|
||||
SplashScreen.hideAsync();
|
||||
});
|
||||
}
|
||||
|
|
@ -77,23 +80,25 @@ export default function RootLayout() {
|
|||
|
||||
return (
|
||||
<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.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>
|
||||
</KeyboardProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,6 +25,7 @@ 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,
|
||||
|
|
@ -79,6 +80,7 @@ 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)) {
|
||||
|
|
@ -112,17 +114,24 @@ export default function TaskDetailScreen() {
|
|||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (saving) return;
|
||||
if (!task || !title.trim()) return;
|
||||
await updateTask(task.id, {
|
||||
title: title.trim(),
|
||||
notes: notes.trim() || undefined,
|
||||
priority,
|
||||
dueDate,
|
||||
recurrence,
|
||||
listId: selectedListId,
|
||||
});
|
||||
await setTagsForTask(task.id, selectedTagIds);
|
||||
router.back();
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
|
|
@ -134,7 +143,7 @@ export default function TaskDetailScreen() {
|
|||
onPress: async () => {
|
||||
await deleteTask(id!);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
router.back();
|
||||
goBack(router);
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
@ -178,7 +187,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={() => router.back()} className="p-1">
|
||||
<Pressable onPress={() => goBack(router)} className="p-2.5" hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<ArrowLeft size={24} color={isDark ? '#F5F5F5' : '#1A1A1A'} />
|
||||
</Pressable>
|
||||
<View className="flex-row items-center">
|
||||
|
|
@ -188,21 +197,22 @@ export default function TaskDetailScreen() {
|
|||
[{ id: id!, title, notes: notes || null, dueDate, priority, completed: task.completed, recurrence }],
|
||||
title
|
||||
)}
|
||||
className="mr-3 p-1"
|
||||
className="mr-3 p-2.5"
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Download size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable onPress={handleDelete} className="mr-3 p-1">
|
||||
<Pressable onPress={handleDelete} className="mr-3 p-2.5" hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<Trash2 size={20} color={colors.terracotta.DEFAULT} />
|
||||
</Pressable>
|
||||
<Pressable onPress={handleSave} className="rounded-lg bg-bleu px-4 py-1.5">
|
||||
<Pressable onPress={handleSave} disabled={saving} className={`rounded-lg bg-bleu px-4 py-2 ${saving ? 'opacity-50' : ''}`}>
|
||||
<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">
|
||||
<KeyboardAwareScrollView className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled" bottomOffset={20}>
|
||||
{/* Title */}
|
||||
<TextInput
|
||||
value={title}
|
||||
|
|
@ -398,8 +408,8 @@ export default function TaskDetailScreen() {
|
|||
/>
|
||||
</View>
|
||||
|
||||
<View className="h-24" />
|
||||
</ScrollView>
|
||||
<View style={{ height: 32 }} />
|
||||
</KeyboardAwareScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
X, Calendar, Repeat, Plus,
|
||||
List, ShoppingCart, Briefcase, Home, Heart, Star, BookOpen,
|
||||
GraduationCap, Dumbbell, Utensils, Plane, Music, Code, Wrench,
|
||||
Gift, Camera, Palette, Dog, Leaf, Zap,
|
||||
|
|
@ -27,6 +27,7 @@ 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> = {
|
||||
|
|
@ -55,6 +56,9 @@ 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);
|
||||
|
|
@ -62,7 +66,9 @@ export default function NewTaskScreen() {
|
|||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (saving) return;
|
||||
if (!title.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const taskId = await createTask({
|
||||
title: title.trim(),
|
||||
|
|
@ -75,9 +81,13 @@ export default function NewTaskScreen() {
|
|||
if (selectedTagIds.length > 0) {
|
||||
await setTagsForTask(taskId, selectedTagIds);
|
||||
}
|
||||
router.back();
|
||||
for (const sub of pendingSubtasks) {
|
||||
await createTask({ title: sub, listId: selectedListId, parentId: taskId });
|
||||
}
|
||||
goBack(router);
|
||||
} catch {
|
||||
// FK constraint or other DB error — fallback to inbox
|
||||
setSaving(false);
|
||||
setSelectedListId(getInboxId());
|
||||
}
|
||||
};
|
||||
|
|
@ -87,6 +97,16 @@ 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]
|
||||
|
|
@ -101,7 +121,7 @@ export default function NewTaskScreen() {
|
|||
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
|
||||
}`}
|
||||
>
|
||||
<Pressable onPress={() => router.back()} className="p-1">
|
||||
<Pressable onPress={() => goBack(router)} className="p-2.5" hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<X size={24} color={isDark ? '#F5F5F5' : '#1A1A1A'} />
|
||||
</Pressable>
|
||||
<Text
|
||||
|
|
@ -110,14 +130,14 @@ export default function NewTaskScreen() {
|
|||
>
|
||||
{t('task.newTask')}
|
||||
</Text>
|
||||
<Pressable onPress={handleSave} className="rounded-lg bg-bleu px-4 py-1.5">
|
||||
<Pressable onPress={handleSave} disabled={saving} className={`rounded-lg bg-bleu px-4 py-2 ${saving ? 'opacity-50' : ''}`}>
|
||||
<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">
|
||||
<KeyboardAwareScrollView className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled" bottomOffset={20}>
|
||||
{/* Title */}
|
||||
<TextInput
|
||||
autoFocus
|
||||
|
|
@ -314,8 +334,50 @@ export default function NewTaskScreen() {
|
|||
</>
|
||||
)}
|
||||
|
||||
<View className="h-24" />
|
||||
</ScrollView>
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
475
package-lock.json
generated
475
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "simpl-liste",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "simpl-liste",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/inter": "^0.4.2",
|
||||
"@expo/ngrok": "^4.1.3",
|
||||
|
|
@ -43,6 +43,7 @@
|
|||
"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",
|
||||
|
|
@ -1593,418 +1594,6 @@
|
|||
"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",
|
||||
|
|
@ -3717,9 +3306,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@react-native/codegen/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
|
|
@ -7137,9 +6726,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
|
||||
"integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
|
|
@ -8896,12 +8485,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
|
|
@ -10395,6 +9984,20 @@
|
|||
"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",
|
||||
|
|
@ -10595,9 +10198,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-native/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
|
|
@ -10994,9 +10597,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/rimraf/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
|
|
@ -11601,9 +11204,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.9",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
|
||||
"integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
|
||||
"version": "7.5.10",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz",
|
||||
"integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
|
|
@ -11720,9 +11323,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "simpl-liste",
|
||||
"main": "index.js",
|
||||
"version": "1.0.1",
|
||||
"version": "1.3.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
|
|
@ -44,6 +44,7 @@
|
|||
"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",
|
||||
|
|
@ -59,5 +60,8 @@
|
|||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "^0.25.0"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,18 +85,19 @@ 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 [dir(tasks.priority), asc(tasks.position)];
|
||||
return [asc(tasks.completed), dir(tasks.priority), asc(tasks.position)];
|
||||
case 'dueDate':
|
||||
return [dir(tasks.dueDate), asc(tasks.position)];
|
||||
return [asc(tasks.completed), dir(tasks.dueDate), asc(tasks.position)];
|
||||
case 'title':
|
||||
return [dir(tasks.title)];
|
||||
return [asc(tasks.completed), dir(tasks.title)];
|
||||
case 'createdAt':
|
||||
return [dir(tasks.createdAt)];
|
||||
return [asc(tasks.completed), dir(tasks.createdAt)];
|
||||
case 'position':
|
||||
default:
|
||||
return [asc(tasks.position), desc(tasks.createdAt)];
|
||||
return [asc(tasks.completed), asc(tasks.position), desc(tasks.createdAt)];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +106,7 @@ export async function getSubtasks(parentId: string) {
|
|||
.select()
|
||||
.from(tasks)
|
||||
.where(eq(tasks.parentId, parentId))
|
||||
.orderBy(asc(tasks.position));
|
||||
.orderBy(asc(tasks.completed), asc(tasks.position));
|
||||
}
|
||||
|
||||
export async function getTaskById(id: string) {
|
||||
|
|
|
|||
|
|
@ -138,6 +138,10 @@
|
|||
"overdue": "Overdue",
|
||||
"today": "Today",
|
||||
"tomorrow": "Tomorrow",
|
||||
"noDate": "No date"
|
||||
"noDate": "No date",
|
||||
"period": "Display period",
|
||||
"periodWeek_one": "{{count}} week",
|
||||
"periodWeek_other": "{{count}} weeks",
|
||||
"periodAll": "All"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,6 +138,10 @@
|
|||
"overdue": "En retard",
|
||||
"today": "Aujourd'hui",
|
||||
"tomorrow": "Demain",
|
||||
"noDate": "Sans date"
|
||||
"noDate": "Sans date",
|
||||
"period": "Période affichée",
|
||||
"periodWeek_one": "{{count}} semaine",
|
||||
"periodWeek_other": "{{count}} semaines",
|
||||
"periodAll": "Toutes"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
src/lib/navigation.ts
Normal file
13
src/lib/navigation.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
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('/');
|
||||
}
|
||||
};
|
||||
|
|
@ -3,13 +3,19 @@ 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 } from 'drizzle-orm';
|
||||
import { eq, and, isNull, gte, lte, lt, asc, sql } 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;
|
||||
title: string;
|
||||
|
|
@ -17,6 +23,9 @@ export interface WidgetTask {
|
|||
dueDate: string | null;
|
||||
completed: boolean;
|
||||
listColor: string | null;
|
||||
subtaskCount: number;
|
||||
subtaskDoneCount: number;
|
||||
subtasks: WidgetSubtask[];
|
||||
}
|
||||
|
||||
export async function syncWidgetData(): Promise<void> {
|
||||
|
|
@ -25,7 +34,20 @@ export async function syncWidgetData(): Promise<void> {
|
|||
try {
|
||||
const now = new Date();
|
||||
const todayStart = startOfDay(now);
|
||||
const twoWeeksEnd = endOfDay(addWeeks(now, 2));
|
||||
|
||||
// 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 selectFields = {
|
||||
id: tasks.id,
|
||||
|
|
@ -35,21 +57,24 @@ 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 tasks with due date in the next 2 weeks
|
||||
// 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))));
|
||||
}
|
||||
const upcomingTasks = await db
|
||||
.select(selectFields)
|
||||
.from(tasks)
|
||||
.leftJoin(lists, eq(tasks.listId, lists.id))
|
||||
.where(
|
||||
and(
|
||||
eq(tasks.completed, false),
|
||||
isNull(tasks.parentId),
|
||||
gte(tasks.dueDate, todayStart),
|
||||
lte(tasks.dueDate, twoWeeksEnd)
|
||||
)
|
||||
)
|
||||
.where(and(...upcomingConditions))
|
||||
.orderBy(asc(tasks.dueDate));
|
||||
|
||||
// Fetch overdue tasks
|
||||
|
|
@ -78,7 +103,7 @@ export async function syncWidgetData(): Promise<void> {
|
|||
isNull(tasks.dueDate)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(tasks.position));
|
||||
.orderBy(asc(tasks.completed), asc(tasks.position));
|
||||
|
||||
const toWidgetTask = (t: typeof upcomingTasks[number]): WidgetTask => ({
|
||||
id: t.id,
|
||||
|
|
@ -87,6 +112,9 @@ 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
|
||||
|
|
@ -96,6 +124,18 @@ 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 {
|
||||
|
|
@ -116,6 +156,18 @@ export async function syncWidgetData(): Promise<void> {
|
|||
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'];
|
||||
for (const widgetName of widgetNames) {
|
||||
|
|
@ -123,7 +175,7 @@ export async function syncWidgetData(): Promise<void> {
|
|||
await requestWidgetUpdate({
|
||||
widgetName,
|
||||
renderWidget: (props) =>
|
||||
TaskListWidget({ ...props, widgetName, tasks: allTasks, isDark }),
|
||||
TaskListWidget({ ...props, widgetName, tasks: allTasks, isDark, expandedTaskIds }),
|
||||
widgetNotFound: () => {},
|
||||
});
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ 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>()(
|
||||
|
|
@ -25,11 +27,13 @@ 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',
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react';
|
||||
import { FlexWidget, TextWidget } from 'react-native-android-widget';
|
||||
import { FlexWidget, ListWidget, TextWidget } from 'react-native-android-widget';
|
||||
import type { WidgetInfo } from 'react-native-android-widget';
|
||||
|
||||
type HexColor = `#${string}`;
|
||||
type ColorProp = HexColor;
|
||||
import type { WidgetTask } from '../services/widgetSync';
|
||||
import type { WidgetTask, WidgetSubtask } from '../services/widgetSync';
|
||||
import {
|
||||
isToday,
|
||||
isTomorrow,
|
||||
|
|
@ -77,101 +77,234 @@ function getDateLabel(dueDate: string | null, c: ReturnType<typeof getColors>):
|
|||
};
|
||||
}
|
||||
|
||||
interface TaskListWidgetProps extends WidgetInfo {
|
||||
widgetName: string;
|
||||
tasks?: WidgetTask[];
|
||||
isDark?: boolean;
|
||||
}
|
||||
|
||||
function TaskItemRow({ task, isDark }: { task: WidgetTask; isDark: boolean }) {
|
||||
function SubtaskItemRow({
|
||||
subtask,
|
||||
parentId,
|
||||
isDark,
|
||||
}: {
|
||||
subtask: WidgetSubtask;
|
||||
parentId: string;
|
||||
isDark: boolean;
|
||||
}) {
|
||||
const c = getColors(isDark);
|
||||
const dateInfo = getDateLabel(task.dueDate, c);
|
||||
const priorityColor = getPriorityDotColor(task.priority);
|
||||
|
||||
return (
|
||||
<FlexWidget
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
paddingLeft: 44,
|
||||
paddingRight: 12,
|
||||
paddingVertical: 5,
|
||||
width: 'match_parent',
|
||||
borderBottomWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: isDark ? '#232323' as ColorProp : '#FFF4E8' as ColorProp,
|
||||
}}
|
||||
clickAction="OPEN_URI"
|
||||
clickActionData={{ uri: `simplliste:///task/${task.id}` }}
|
||||
>
|
||||
{/* List color indicator */}
|
||||
{/* Subtask checkbox */}
|
||||
<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,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
borderWidth: 2,
|
||||
borderColor: c.checkboxUnchecked,
|
||||
marginRight: 10,
|
||||
borderColor: subtask.completed ? TODAY_COLOR : c.checkboxUnchecked,
|
||||
backgroundColor: subtask.completed ? TODAY_COLOR : '#00000000' as ColorProp,
|
||||
marginRight: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
clickAction="TOGGLE_COMPLETE"
|
||||
clickActionData={{ taskId: task.id }}
|
||||
clickAction="TOGGLE_SUBTASK"
|
||||
clickActionData={{ subtaskId: subtask.id, parentId }}
|
||||
/>
|
||||
|
||||
{/* Priority dot + title */}
|
||||
{/* 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 */}
|
||||
<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}` }}
|
||||
>
|
||||
{priorityColor != null ? (
|
||||
{/* 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 ? (
|
||||
<FlexWidget
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: priorityColor,
|
||||
marginRight: 6,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: isDark ? '#2A2A2A' as ColorProp : '#F0E8DC' as ColorProp,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 6,
|
||||
}}
|
||||
/>
|
||||
clickAction="TOGGLE_EXPAND"
|
||||
clickActionData={{ taskId: task.id }}
|
||||
>
|
||||
<TextWidget
|
||||
text={isExpanded ? '▾' : '▸'}
|
||||
style={{
|
||||
fontSize: 18,
|
||||
fontFamily: FONT_SEMIBOLD,
|
||||
color: TODAY_COLOR,
|
||||
}}
|
||||
/>
|
||||
</FlexWidget>
|
||||
) : null}
|
||||
<FlexWidget style={{ flex: 1 }}>
|
||||
<TextWidget
|
||||
text={task.title}
|
||||
maxLines={1}
|
||||
truncate="END"
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontFamily: FONT_REGULAR,
|
||||
color: c.text,
|
||||
}}
|
||||
/>
|
||||
</FlexWidget>
|
||||
|
||||
{/* Date label */}
|
||||
<TextWidget
|
||||
text={dateInfo.text}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontFamily: FONT_REGULAR,
|
||||
color: dateInfo.color,
|
||||
marginLeft: 8,
|
||||
}}
|
||||
/>
|
||||
</FlexWidget>
|
||||
|
||||
{/* Date label */}
|
||||
<TextWidget
|
||||
text={dateInfo.text}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontFamily: FONT_REGULAR,
|
||||
color: dateInfo.color,
|
||||
marginLeft: 8,
|
||||
}}
|
||||
/>
|
||||
{/* 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}
|
||||
</FlexWidget>
|
||||
);
|
||||
}
|
||||
|
|
@ -247,15 +380,14 @@ function SmallWidget({ tasks, isDark }: { tasks: WidgetTask[]; isDark: boolean }
|
|||
|
||||
function ListWidgetContent({
|
||||
tasks,
|
||||
maxItems,
|
||||
isDark,
|
||||
expandedTaskIds,
|
||||
}: {
|
||||
tasks: WidgetTask[];
|
||||
maxItems: number;
|
||||
isDark: boolean;
|
||||
expandedTaskIds: Set<string>;
|
||||
}) {
|
||||
const c = getColors(isDark);
|
||||
const displayTasks = tasks.slice(0, maxItems);
|
||||
|
||||
return (
|
||||
<FlexWidget
|
||||
|
|
@ -280,29 +412,30 @@ function ListWidgetContent({
|
|||
borderBottomWidth: 1,
|
||||
borderColor: c.border,
|
||||
}}
|
||||
clickAction="OPEN_APP"
|
||||
>
|
||||
<TextWidget
|
||||
text="Simpl-Liste"
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontFamily: FONT_SEMIBOLD,
|
||||
color: c.text,
|
||||
}}
|
||||
/>
|
||||
<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,
|
||||
marginRight: 4,
|
||||
marginLeft: 8,
|
||||
}}
|
||||
/>
|
||||
<TextWidget
|
||||
|
|
@ -314,21 +447,56 @@ function ListWidgetContent({
|
|||
}}
|
||||
/>
|
||||
</FlexWidget>
|
||||
</FlexWidget>
|
||||
|
||||
{/* Task list */}
|
||||
{displayTasks.length > 0 ? (
|
||||
{/* Add button */}
|
||||
<FlexWidget
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
</FlexWidget>
|
||||
</FlexWidget>
|
||||
|
||||
{/* Task list — cap at 30 items to avoid Android widget memory limits */}
|
||||
{tasks.length > 0 ? (
|
||||
<ListWidget
|
||||
style={{
|
||||
height: 'match_parent',
|
||||
width: 'match_parent',
|
||||
}}
|
||||
>
|
||||
{displayTasks.map((task) => (
|
||||
<TaskItemRow key={task.id} task={task} isDark={isDark} />
|
||||
{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>
|
||||
))}
|
||||
</FlexWidget>
|
||||
</ListWidget>
|
||||
) : (
|
||||
<FlexWidget
|
||||
style={{
|
||||
|
|
@ -349,39 +517,6 @@ function ListWidgetContent({
|
|||
</FlexWidget>
|
||||
)}
|
||||
|
||||
{/* Add button footer */}
|
||||
<FlexWidget
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 8,
|
||||
width: 'match_parent',
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -390,11 +525,17 @@ 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} />;
|
||||
}
|
||||
|
||||
const maxItems = widgetName === 'SimplListeLarge' ? 8 : 4;
|
||||
return <ListWidgetContent tasks={widgetTasks} maxItems={maxItems} isDark={isDark} />;
|
||||
return (
|
||||
<ListWidgetContent
|
||||
tasks={widgetTasks}
|
||||
isDark={isDark}
|
||||
expandedTaskIds={expandedTaskIds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { TaskListWidget } from './TaskListWidget';
|
|||
import { WIDGET_DATA_KEY, WIDGET_DARK_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>;
|
||||
|
|
@ -13,7 +15,9 @@ 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.listColor === null || obj.listColor === undefined || typeof obj.listColor === 'string') &&
|
||||
(obj.subtaskCount === undefined || typeof obj.subtaskCount === 'number') &&
|
||||
(obj.subtaskDoneCount === undefined || typeof obj.subtaskDoneCount === 'number')
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -23,7 +27,10 @@ async function getWidgetTasks(): Promise<WidgetTask[]> {
|
|||
if (!data) return [];
|
||||
const parsed: unknown = JSON.parse(data);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(isWidgetTask);
|
||||
return parsed.filter(isWidgetTask).map((t) => ({
|
||||
...t,
|
||||
subtasks: Array.isArray(t.subtasks) ? t.subtasks : [],
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -39,6 +46,40 @@ async function getWidgetIsDark(): Promise<boolean> {
|
|||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
|
|
@ -48,15 +89,12 @@ export async function widgetTaskHandler(
|
|||
case 'WIDGET_ADDED':
|
||||
case 'WIDGET_UPDATE':
|
||||
case 'WIDGET_RESIZED': {
|
||||
const [tasks, isDark] = await Promise.all([getWidgetTasks(), getWidgetIsDark()]);
|
||||
renderWidget(
|
||||
TaskListWidget({
|
||||
...widgetInfo,
|
||||
widgetName: widgetInfo.widgetName,
|
||||
tasks,
|
||||
isDark,
|
||||
})
|
||||
);
|
||||
const [tasks, isDark, expandedTaskIds] = await Promise.all([
|
||||
getWidgetTasks(),
|
||||
getWidgetIsDark(),
|
||||
getExpandedTaskIds(),
|
||||
]);
|
||||
renderWithState(renderWidget, widgetInfo, tasks, isDark, expandedTaskIds);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -68,32 +106,73 @@ export async function widgetTaskHandler(
|
|||
const taskId = props.clickActionData?.taskId;
|
||||
if (!isValidUUID(taskId)) break;
|
||||
|
||||
// Update the cached data to remove the completed task immediately
|
||||
const [tasks, isDark] = await Promise.all([getWidgetTasks(), getWidgetIsDark()]);
|
||||
const [tasks, isDark, expandedTaskIds] = await Promise.all([
|
||||
getWidgetTasks(),
|
||||
getWidgetIsDark(),
|
||||
getExpandedTaskIds(),
|
||||
]);
|
||||
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));
|
||||
|
||||
// Re-render the widget with updated data
|
||||
renderWidget(
|
||||
TaskListWidget({
|
||||
...widgetInfo,
|
||||
widgetName: widgetInfo.widgetName,
|
||||
tasks: updatedTasks,
|
||||
isDark,
|
||||
})
|
||||
);
|
||||
renderWithState(renderWidget, widgetInfo, updatedTasks, isDark, expandedTaskIds);
|
||||
|
||||
// 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 — sync on next app open
|
||||
// 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
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
|
|
|||
Loading…
Reference in a new issue