Compare commits

...

34 commits

Author SHA1 Message Date
le king fu
704ca9f693 fix: bump versionCode to 6 for APK upgrade compatibility
Previous preview build (v1.2.5) used versionCode 5 via production
autoIncrement, but app.json was still at 4. Android refuses to install
an APK with a lower versionCode than the currently installed one.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:10:29 -04:00
le king fu
72ace1db4a chore: bump version to 1.3.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:26:10 -04:00
3cecf9ba26 Merge pull request 'fix: show all tasks in widget (#23)' (#24) from fix/simpl-liste-23-widget-task-count into master 2026-03-13 00:25:21 +00:00
le king fu
9a8bb13e97 fix: cap widget task list at 30 items to prevent memory issues
Add safety limit of 30 rendered tasks in the widget to avoid Android
memory constraints. Also add cross-reference comment for the implicit
AsyncStorage coupling with useSettingsStore.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:20:47 -04:00
le king fu
2e13528c6b fix: default widget period to all tasks (#23)
Change default widgetPeriodWeeks from 2 to 0 (all tasks) so that the
issue is resolved out of the box without requiring the user to discover
the new setting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:10:45 -04:00
le king fu
f040ec7902 feat: add configurable widget display period setting (#23)
Instead of removing the time filter entirely, let users choose the
widget display period (1 week, 2 weeks, 4 weeks, or all tasks) from
Settings. Default remains 2 weeks for backward compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:04:35 -04:00
dde33acdf2 fix: show all tasks in widget without date or count limits
Remove the 2-week date filter from widget task query so tasks with
distant due dates are included. Remove the maxItems truncation from
the scrollable ListWidget so the displayed list matches the counter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:48:14 -04:00
b5e722c1f0 Merge pull request 'fix: save and back buttons not navigating away from task screen (#21)' (#22) from fix/simpl-liste-21-save-button-navigation into master 2026-03-10 01:26:05 +00:00
4c73a16302 fix: restore error handling and deduplicate goBack helper (#21)
- Replace `finally` with `catch` in [id].tsx handleSave so goBack is
  not called when updateTask/setTagsForTask fails
- Extract shared goBack helper into src/lib/navigation.ts
- Both [id].tsx and new.tsx now import goBack from the shared module

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:06:05 -04:00
2296126ba4 fix: use goBack helper with canGoBack fallback and reset saving state (#21)
Replace all router.back() calls with a goBack() helper that checks
router.canGoBack() first and falls back to router.replace('/') when
there is no screen to return to. In the task edit screen, change
catch to finally so the save button always resets its disabled state
even if updateTask/setTagsForTask throws.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:47:22 -04:00
d6a69d849b Merge pull request 'fix: restore add button in medium/large widget (#19)' (#20) from fix/simpl-liste-19-widget-add-button into master 2026-03-09 23:52:11 +00:00
594896a909 Restore add button in medium/large widget by moving it to header
The ListWidget (Android ListView) introduced for scrolling takes all
available vertical space, pushing the footer add button off-screen.
Move the + button into the header row where it remains always visible
regardless of the task list scroll state.

Related to #19

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:01:52 -04:00
8d34ae5267 chore: bump version to 1.2.5 (versionCode 4)
Includes widget scroll support, completed tasks sorting, and
esbuild vulnerability fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 11:27:13 -04:00
0462b5a50b Merge pull request 'fix: sort completed main tasks to bottom of list (#15)' (#18) from fix/simpl-liste-15-sort-completed-tasks into master 2026-03-08 15:26:01 +00:00
2a7b70c65c Merge pull request 'fix: add scroll support in medium/large widgets (#11)' (#14) from fix/simpl-liste-11-widget-scroll into master 2026-03-08 15:25:16 +00:00
6c1bd043e6 Merge pull request 'fix: resolve esbuild vulnerability via npm override (#16)' (#17) from fix/simpl-liste-16-esbuild-vulnerability into master 2026-03-08 15:25:15 +00:00
2d9440b05c Sort completed main tasks to the bottom of the list
Add asc(tasks.completed) as the primary sort key in getOrderClauses()
so completed tasks always appear after active ones regardless of the
chosen sort mode (position, priority, dueDate, title, createdAt).

Also apply the same completed-first ordering to:
- getSubtasks() in tasks.ts
- noDateTasks query in widgetSync.ts
- subtasks query in widgetSync.ts

Ref: simpl-liste#15

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 11:04:32 -04:00
ce21337042 Add npm override to force esbuild ^0.25.0 across all dependencies
The transitive dependency chain drizzle-kit -> @esbuild-kit/esm-loader ->
@esbuild-kit/core-utils pulled in esbuild@0.18.20 which is vulnerable to
GHSA-67mh-4wv8-2f99. Adding an npm override forces all nested esbuild
instances to use ^0.25.0, resolving all 4 moderate audit findings.

Ref: simpl-liste#16

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 11:02:46 -04:00
le king fu
661ac0aa33 Add scrollable task list in medium/large widgets
Replace FlexWidget with ListWidget for the task list in medium and
large home screen widgets, enabling scroll when items exceed the
widget display area.

Fixes #11

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:56:13 -05:00
le king fu
fa037e9eef fix: increase touch targets for header buttons (#10)
Buttons (X, back, save, delete, export) had ~28px hit areas,
causing missed taps. Increased padding to p-2.5 + hitSlop for
~44px touch targets. Bump version to 1.2.4.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:22:54 -05:00
le king fu
a8efb82b3a Add missing .gitignore patterns (.env, .env.*)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:48:06 -05:00
le king fu
bf7c954528 Fix HIGH vulnerability (minimatch ReDoS)
npm audit fix to resolve ReDoS in minimatch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:27:04 -05:00
le king fu
64cd7bc896 chore: bump version to 1.2.3
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:28:09 -05:00
le king fu
f2fe141737 fix: use react-native-keyboard-controller for reliable keyboard handling (#6)
Replace manual keyboard listeners and RN KeyboardAvoidingView with
react-native-keyboard-controller which handles edge-to-edge correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:18:45 -05:00
le king fu
360310e99f fix: replace KeyboardAvoidingView with manual keyboard padding (#6)
KeyboardAvoidingView does not work with edgeToEdgeEnabled on Android.
New approach: listen to Keyboard events, dynamically set spacer height
to actual keyboard height, and scrollToEnd when subtask input is focused.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:40:58 -05:00
le king fu
9835f9ef18 fix: use KeyboardAvoidingView behavior="padding" on Android (#6)
edge-to-edge mode disables classic adjustResize, so behavior must be
"padding" on both platforms to push content above the keyboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:25:30 -05:00
le king fu
fe7bda4747 fix: disable KeyboardAvoidingView on Android, sync widget on app start (#6, #9)
- Set KeyboardAvoidingView behavior to undefined on Android to avoid
  conflict with native adjustResize (was causing double-resize)
- Call syncWidgetData() at app startup to populate subtasks in widget cache
- Keyboard.addListener + adjustResize handles scroll correctly on Android

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:07:34 -05:00
le king fu
3efb7a1cb0 chore: bump version to 1.2.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:29:48 -05:00
le king fu
a03085c768 fix: keyboard scroll via Keyboard listener, bigger widget expand button (#6, #9)
- Replace unreliable setTimeout scroll with Keyboard.addListener('keyboardDidShow')
- Track subtask input focus state to scroll only when relevant
- Increase bottom spacer from h-32 to h-80 for more scroll room
- Enlarge expand/collapse button (32px with background) and arrow (fontSize 18)
- Ensure subtasks array is always initialized in widget handler

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:04:38 -05:00
le king fu
55e02e1b3a chore: bump version to 1.2.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:43:44 -05:00
le king fu
117de533d7 fix: subtask input scroll + widget expand/collapse subtasks (#6, #9)
- Fix keyboard hiding subtask input: use precise scrollTo with onLayout position instead of unreliable scrollToEnd (#6)
- Add expand/collapse button in widget for tasks with subtasks (#9)
- Subtasks are now toggleable directly from the widget
- Widget state (expanded tasks) persisted via AsyncStorage
- Update CLAUDE.md with widget docs, build/deploy process, and release workflow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:27:21 -05:00
le king fu
2412d368ac chore: bump version to 1.1.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:18:09 -05:00
le king fu
f61ce64b50 feat: show subtask progress in Android widget (#9)
- Add subtaskCount/subtaskDoneCount to WidgetTask with correlated SQL subqueries
- Display subtask indicator (✓ done/total) below task title in widget rows
- Update type guard for new fields in headless handler

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:47:47 -05:00
le king fu
72eafbd9d9 fix: prevent double-tap save, add subtasks at creation, fix keyboard overlap (#7, #8, #6)
- Add saving guard to prevent duplicate task creation on rapid taps
- Add pending subtasks UI to new task screen with local add/remove
- Wrap both task screens in KeyboardAvoidingView with scrollToEnd on subtask focus

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:27:03 -05:00
20 changed files with 785 additions and 672 deletions

7
.claude/rules/i18n.md Normal file
View 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.

View 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
View file

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

View file

@ -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é.

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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>
);
}

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,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>
);
}

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,
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
View file

@ -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"

View file

@ -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
}

View file

@ -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) {

View file

@ -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"
}
}

View file

@ -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
View 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('/');
}
};

View file

@ -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 {

View file

@ -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',

View file

@ -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}
/>
);
}

View file

@ -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;