From 117de533d715f961164225219c443e3b05746eb4 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 1 Mar 2026 10:27:21 -0500 Subject: [PATCH] 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 --- CLAUDE.md | 74 +++++++- app/task/[id].tsx | 8 +- app/task/new.tsx | 8 +- src/services/widgetSync.ts | 34 +++- src/widgets/TaskListWidget.tsx | 292 ++++++++++++++++++++++--------- src/widgets/widgetTaskHandler.ts | 132 +++++++++++--- 6 files changed, 430 insertions(+), 118 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 641e589..c8d6b16 100644 --- a/CLAUDE.md +++ b/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é. diff --git a/app/task/[id].tsx b/app/task/[id].tsx index 4c10db5..9cffc2e 100644 --- a/app/task/[id].tsx +++ b/app/task/[id].tsx @@ -82,6 +82,7 @@ export default function TaskDetailScreen() { const [selectedListId, setSelectedListId] = useState(''); const [saving, setSaving] = useState(false); const scrollRef = useRef(null); + const subtaskInputY = useRef(0); useEffect(() => { if (!isValidUUID(id)) { @@ -395,13 +396,16 @@ export default function TaskDetailScreen() { ))} {/* Add subtask */} - + { subtaskInputY.current = e.nativeEvent.layout.y; }} + > setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 300)} + onFocus={() => setTimeout(() => scrollRef.current?.scrollTo({ y: subtaskInputY.current - 80, animated: true }), 300)} placeholder={t('task.addSubtask')} placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'} className={`ml-2 flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`} diff --git a/app/task/new.tsx b/app/task/new.tsx index 618bd18..80774c2 100644 --- a/app/task/new.tsx +++ b/app/task/new.tsx @@ -60,6 +60,7 @@ export default function NewTaskScreen() { const [pendingSubtasks, setPendingSubtasks] = useState([]); const [newSubtask, setNewSubtask] = useState(''); const scrollRef = useRef(null); + const subtaskInputY = useRef(0); useEffect(() => { getAllLists().then(setLists); @@ -365,13 +366,16 @@ export default function NewTaskScreen() { ))} {/* Add subtask */} - + { subtaskInputY.current = e.nativeEvent.layout.y; }} + > setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 300)} + onFocus={() => setTimeout(() => scrollRef.current?.scrollTo({ y: subtaskInputY.current - 80, animated: true }), 300)} placeholder={t('task.addSubtask')} placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'} className={`ml-2 flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`} diff --git a/src/services/widgetSync.ts b/src/services/widgetSync.ts index 14d6426..c78805a 100644 --- a/src/services/widgetSync.ts +++ b/src/services/widgetSync.ts @@ -10,6 +10,12 @@ 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; @@ -19,6 +25,7 @@ export interface WidgetTask { listColor: string | null; subtaskCount: number; subtaskDoneCount: number; + subtasks: WidgetSubtask[]; } export async function syncWidgetData(): Promise { @@ -93,6 +100,7 @@ export async function syncWidgetData(): Promise { listColor: t.listColor, subtaskCount: t.subtaskCount ?? 0, subtaskDoneCount: t.subtaskDoneCount ?? 0, + subtasks: [], }); // Combine: overdue first, then upcoming, then no date @@ -102,6 +110,18 @@ export async function syncWidgetData(): Promise { ...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.position)); + task.subtasks = subs; + } + } + // Determine dark mode from settings let isDark = false; try { @@ -122,6 +142,18 @@ export async function syncWidgetData(): Promise { 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) { @@ -129,7 +161,7 @@ export async function syncWidgetData(): Promise { await requestWidgetUpdate({ widgetName, renderWidget: (props) => - TaskListWidget({ ...props, widgetName, tasks: allTasks, isDark }), + TaskListWidget({ ...props, widgetName, tasks: allTasks, isDark, expandedTaskIds }), widgetNotFound: () => {}, }); } catch { diff --git a/src/widgets/TaskListWidget.tsx b/src/widgets/TaskListWidget.tsx index 47b41bf..8939f5d 100644 --- a/src/widgets/TaskListWidget.tsx +++ b/src/widgets/TaskListWidget.tsx @@ -4,7 +4,7 @@ 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,112 +77,233 @@ function getDateLabel(dueDate: string | null, c: ReturnType): }; } -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 ( - {/* List color indicator */} + {/* Subtask checkbox */} - - {/* Checkbox */} - - {/* Priority dot + title */} + {/* Subtask title */} + + + + + ); +} + +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 ( + + {/* Main task row */} - {priorityColor != null ? ( - - ) : null} - - - {(task.subtaskCount ?? 0) > 0 ? ( - + + {/* Checkbox */} + + + {/* Priority dot + title + subtask indicator */} + + {priorityColor != null ? ( + ) : null} + + + {hasSubtasks ? ( + + ) : null} + + + {/* Expand/collapse button */} + {hasSubtasks ? ( + + + + ) : null} + + {/* Date label */} + - {/* Date label */} - + {/* Expanded subtasks */} + {isExpanded && task.subtasks?.length ? ( + + {task.subtasks.map((sub) => ( + + ))} + + ) : null} ); } @@ -260,10 +381,12 @@ function ListWidgetContent({ tasks, maxItems, isDark, + expandedTaskIds, }: { tasks: WidgetTask[]; maxItems: number; isDark: boolean; + expandedTaskIds: Set; }) { const c = getColors(isDark); const displayTasks = tasks.slice(0, maxItems); @@ -337,7 +460,12 @@ function ListWidgetContent({ }} > {displayTasks.map((task) => ( - + ))} ) : ( @@ -401,11 +529,19 @@ 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 ; } const maxItems = widgetName === 'SimplListeLarge' ? 8 : 4; - return ; + return ( + + ); } diff --git a/src/widgets/widgetTaskHandler.ts b/src/widgets/widgetTaskHandler.ts index acb6e11..531b7fc 100644 --- a/src/widgets/widgetTaskHandler.ts +++ b/src/widgets/widgetTaskHandler.ts @@ -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; @@ -41,6 +43,40 @@ async function getWidgetIsDark(): Promise { } } +async function getExpandedTaskIds(): Promise> { + 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): Promise { + await AsyncStorage.setItem(WIDGET_EXPANDED_KEY, JSON.stringify([...ids])); +} + +function renderWithState( + renderWidget: WidgetTaskHandlerProps['renderWidget'], + widgetInfo: WidgetTaskHandlerProps['widgetInfo'], + tasks: WidgetTask[], + isDark: boolean, + expandedTaskIds: Set, +) { + renderWidget( + TaskListWidget({ + ...widgetInfo, + widgetName: widgetInfo.widgetName, + tasks, + isDark, + expandedTaskIds: [...expandedTaskIds], + }) + ); +} + export async function widgetTaskHandler( props: WidgetTaskHandlerProps ): Promise { @@ -50,15 +86,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; } @@ -70,32 +103,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;