From 810bf2e939aaf3257dd2b8e5ae14a8d8d0cbb5c1 Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 30 Mar 2026 19:45:02 -0400 Subject: [PATCH] fix: consolidate widget AsyncStorage keys and debounce expand (#29) Merge widget:tasks, widget:isDark, and widget:expandedTaskIds into a single widget:state key to reduce AsyncStorage I/O from 3 reads to 1. Add 2s debounce on TOGGLE_EXPAND to prevent double-tap from collapsing the subtask list. Legacy keys are migrated on first read. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 32 ++++++--- src/services/widgetSync.ts | 71 ++++++++++++++++---- src/widgets/widgetTaskHandler.ts | 107 +++++++++---------------------- 3 files changed, 109 insertions(+), 101 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c8d6b16..2a79da1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,17 +129,17 @@ Couleurs sombres : fond `#1A1A1A`, surface `#2A2A2A`, bordure `#3A3A3A`, texte ` - **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 +- `widgetSync.ts` lit les tâches depuis SQLite et les cache dans AsyncStorage (`widget:state`) +- Le thème est lu depuis AsyncStorage (`simpl-liste-settings` → `state.theme`), résolu si `system` via `Appearance.getColorScheme()` +- `widgetTaskHandler.ts` gère le rendu headless (quand l'app n'est pas ouverte) en lisant la clé consolidée `widget:state` - Les couleurs du widget suivent la même palette que l'app (voir `LIGHT_COLORS` / `DARK_COLORS` dans `TaskListWidget.tsx`) +- Un debounce de 2s sur `TOGGLE_EXPAND` empêche les double-taps d'annuler l'expansion ### 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`) | +| `widget:state` | `WidgetState` sérialisé JSON (tasks, isDark, expandedTaskIds) | +| `simpl-liste-settings` | Store Zustand persisté (contient `state.theme`, `state.widgetPeriodWeeks`) | ## Build & déploiement @@ -160,16 +160,28 @@ npx eas-cli build --platform android --profile production --non-interactive # AA ### 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 : +2. **Bumper `android.versionCode` dans `app.json`** — doit être **strictement supérieur** au versionCode du dernier build publié. Android refuse d'installer un APK avec un versionCode égal ou inférieur. Vérifier le dernier versionCode avec : ```bash + npx --yes eas-cli build:list --platform android --limit 1 --json 2>/dev/null | jq '.[0].appBuildVersion' + ``` + ⚠️ `autoIncrement: true` dans eas.json ne s'applique qu'au profil `production`. Pour le profil `preview` (APK), le versionCode vient directement de `app.json` — il faut le mettre à jour manuellement. +3. Commit le bump de version, tag, push +4. Build preview (APK) : + ```bash + npx --yes eas-cli build --platform android --profile preview --non-interactive + ``` +5. Télécharger l'APK et créer la release sur Forgejo : + ```bash + # Récupérer l'URL de l'APK + npx --yes eas-cli build:list --platform android --limit 1 --json 2>/dev/null | jq -r '.[0].artifacts.buildUrl' + # Télécharger + curl -L -o simpl-liste-vX.Y.Z.apk "" # 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` +6. 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 diff --git a/src/services/widgetSync.ts b/src/services/widgetSync.ts index 38755dd..5d7693e 100644 --- a/src/services/widgetSync.ts +++ b/src/services/widgetSync.ts @@ -7,8 +7,12 @@ 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 const WIDGET_STATE_KEY = 'widget:state'; + +// Legacy keys — used for migration only +const LEGACY_DATA_KEY = 'widget:tasks'; +const LEGACY_DARK_KEY = 'widget:isDark'; +const LEGACY_EXPANDED_KEY = 'widget:expandedTaskIds'; export interface WidgetSubtask { id: string; @@ -28,6 +32,51 @@ export interface WidgetTask { subtasks: WidgetSubtask[]; } +export interface WidgetState { + tasks: WidgetTask[]; + isDark: boolean; + expandedTaskIds: string[]; +} + +export async function getWidgetState(): Promise { + try { + const raw = await AsyncStorage.getItem(WIDGET_STATE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + return { + tasks: Array.isArray(parsed.tasks) ? parsed.tasks : [], + isDark: parsed.isDark === true, + expandedTaskIds: Array.isArray(parsed.expandedTaskIds) ? parsed.expandedTaskIds : [], + }; + } + + // Migration from legacy keys + const [dataRaw, darkRaw, expandedRaw] = await Promise.all([ + AsyncStorage.getItem(LEGACY_DATA_KEY), + AsyncStorage.getItem(LEGACY_DARK_KEY), + AsyncStorage.getItem(LEGACY_EXPANDED_KEY), + ]); + + const state: WidgetState = { + tasks: dataRaw ? JSON.parse(dataRaw) : [], + isDark: darkRaw ? JSON.parse(darkRaw) === true : false, + expandedTaskIds: expandedRaw ? JSON.parse(expandedRaw) : [], + }; + + // Write consolidated key and clean up legacy keys + await AsyncStorage.setItem(WIDGET_STATE_KEY, JSON.stringify(state)); + await AsyncStorage.multiRemove([LEGACY_DATA_KEY, LEGACY_DARK_KEY, LEGACY_EXPANDED_KEY]); + + return state; + } catch { + return { tasks: [], isDark: false, expandedTaskIds: [] }; + } +} + +export async function setWidgetState(state: WidgetState): Promise { + await AsyncStorage.setItem(WIDGET_STATE_KEY, JSON.stringify(state)); +} + export async function syncWidgetData(): Promise { if (Platform.OS !== 'android') return; @@ -35,8 +84,7 @@ export async function syncWidgetData(): Promise { const now = new Date(); const todayStart = startOfDay(now); - // Read widget period setting from AsyncStorage (0 = all, N = N weeks ahead) - // Coupled with useSettingsStore.ts — key 'simpl-liste-settings', path state.widgetPeriodWeeks + // Read widget period setting from AsyncStorage let widgetPeriodWeeks = 0; try { const settingsRaw = await AsyncStorage.getItem('simpl-liste-settings'); @@ -153,21 +201,18 @@ export async function syncWidgetData(): Promise { // Default to light } - await AsyncStorage.setItem(WIDGET_DATA_KEY, JSON.stringify(allTasks)); - await AsyncStorage.setItem(WIDGET_DARK_KEY, JSON.stringify(isDark)); - - // Read expanded state + // Read existing expanded state to preserve it let expandedTaskIds: string[] = []; try { - const expandedRaw = await AsyncStorage.getItem('widget:expandedTaskIds'); - if (expandedRaw) { - const parsed = JSON.parse(expandedRaw); - if (Array.isArray(parsed)) expandedTaskIds = parsed; - } + const existing = await getWidgetState(); + expandedTaskIds = existing.expandedTaskIds; } catch { // Default to none expanded } + const state: WidgetState = { tasks: allTasks, isDark, expandedTaskIds }; + await setWidgetState(state); + // Request widget update for all 3 sizes const widgetNames = ['SimplListeSmall', 'SimplListeMedium', 'SimplListeLarge']; for (const widgetName of widgetNames) { diff --git a/src/widgets/widgetTaskHandler.ts b/src/widgets/widgetTaskHandler.ts index 091b790..eb7ad29 100644 --- a/src/widgets/widgetTaskHandler.ts +++ b/src/widgets/widgetTaskHandler.ts @@ -1,10 +1,10 @@ import type { WidgetTaskHandlerProps } from 'react-native-android-widget'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import { TaskListWidget } from './TaskListWidget'; -import { WIDGET_DATA_KEY, WIDGET_DARK_KEY, type WidgetTask } from '../services/widgetSync'; +import { getWidgetState, setWidgetState, type WidgetTask } from '../services/widgetSync'; import { isValidUUID } from '../lib/validation'; -const WIDGET_EXPANDED_KEY = 'widget:expandedTaskIds'; +const EXPAND_DEBOUNCE_MS = 2000; +const lastExpandTimes = new Map(); function isWidgetTask(item: unknown): item is WidgetTask { if (typeof item !== 'object' || item === null) return false; @@ -21,53 +21,12 @@ function isWidgetTask(item: unknown): item is WidgetTask { ); } -async function getWidgetTasks(): Promise { - try { - const data = await AsyncStorage.getItem(WIDGET_DATA_KEY); - if (!data) return []; - const parsed: unknown = JSON.parse(data); - if (!Array.isArray(parsed)) return []; - return parsed.filter(isWidgetTask).map((t) => ({ - ...t, - subtasks: Array.isArray(t.subtasks) ? t.subtasks : [], - })); - } catch { - return []; - } -} - -async function getWidgetIsDark(): Promise { - try { - const data = await AsyncStorage.getItem(WIDGET_DARK_KEY); - if (!data) return false; - return JSON.parse(data) === true; - } catch { - return false; - } -} - -async function getExpandedTaskIds(): Promise> { - 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, + expandedTaskIds: string[], ) { renderWidget( TaskListWidget({ @@ -75,7 +34,7 @@ function renderWithState( widgetName: widgetInfo.widgetName, tasks, isDark, - expandedTaskIds: [...expandedTaskIds], + expandedTaskIds, }) ); } @@ -89,12 +48,8 @@ export async function widgetTaskHandler( case 'WIDGET_ADDED': case 'WIDGET_UPDATE': case 'WIDGET_RESIZED': { - const [tasks, isDark, expandedTaskIds] = await Promise.all([ - getWidgetTasks(), - getWidgetIsDark(), - getExpandedTaskIds(), - ]); - renderWithState(renderWidget, widgetInfo, tasks, isDark, expandedTaskIds); + const state = await getWidgetState(); + renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds); break; } @@ -106,15 +61,11 @@ export async function widgetTaskHandler( const taskId = props.clickActionData?.taskId; if (!isValidUUID(taskId)) break; - 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)); + const state = await getWidgetState(); + state.tasks = state.tasks.filter((t) => t.id !== taskId); + await setWidgetState(state); - renderWithState(renderWidget, widgetInfo, updatedTasks, isDark, expandedTaskIds); + renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds); try { const { toggleComplete } = await import('../db/repository/tasks'); @@ -128,20 +79,24 @@ export async function widgetTaskHandler( const taskId = props.clickActionData?.taskId as string | undefined; if (!taskId) break; - const [tasks, isDark, expandedTaskIds] = await Promise.all([ - getWidgetTasks(), - getWidgetIsDark(), - getExpandedTaskIds(), - ]); + // Debounce: ignore rapid double-taps on the same task + const now = Date.now(); + const lastTime = lastExpandTimes.get(taskId) ?? 0; + if (now - lastTime < EXPAND_DEBOUNCE_MS) break; + lastExpandTimes.set(taskId, now); - if (expandedTaskIds.has(taskId)) { - expandedTaskIds.delete(taskId); + const state = await getWidgetState(); + const expandedSet = new Set(state.expandedTaskIds); + + if (expandedSet.has(taskId)) { + expandedSet.delete(taskId); } else { - expandedTaskIds.add(taskId); + expandedSet.add(taskId); } - await setExpandedTaskIds(expandedTaskIds); + state.expandedTaskIds = [...expandedSet]; + await setWidgetState(state); - renderWithState(renderWidget, widgetInfo, tasks, isDark, expandedTaskIds); + renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds); } if (props.clickAction === 'TOGGLE_SUBTASK') { @@ -149,14 +104,10 @@ export async function widgetTaskHandler( const parentId = props.clickActionData?.parentId as string | undefined; if (!isValidUUID(subtaskId) || !parentId) break; - const [tasks, isDark, expandedTaskIds] = await Promise.all([ - getWidgetTasks(), - getWidgetIsDark(), - getExpandedTaskIds(), - ]); + const state = await getWidgetState(); // Update subtask state in cached data - const parent = tasks.find((t) => t.id === parentId); + const parent = state.tasks.find((t) => t.id === parentId); if (parent) { const sub = parent.subtasks?.find((s) => s.id === subtaskId); if (sub) { @@ -164,9 +115,9 @@ export async function widgetTaskHandler( parent.subtaskDoneCount = (parent.subtasks ?? []).filter((s) => s.completed).length; } } - await AsyncStorage.setItem(WIDGET_DATA_KEY, JSON.stringify(tasks)); + await setWidgetState(state); - renderWithState(renderWidget, widgetInfo, tasks, isDark, expandedTaskIds); + renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds); try { const { toggleComplete } = await import('../db/repository/tasks');