Compare commits

..

3 commits

Author SHA1 Message Date
244fbee405 Merge pull request 'fix: consolidate widget AsyncStorage and debounce expand (#29)' (#31) from issue-29-widget-expand-perf into master 2026-03-31 00:13:13 +00:00
le king fu
992c983026 chore: remove unused isWidgetTask function (#29)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:00:19 -04:00
le king fu
810bf2e939 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) <noreply@anthropic.com>
2026-03-30 19:45:02 -04:00
3 changed files with 109 additions and 116 deletions

View file

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

View file

@ -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<WidgetState> {
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<void> {
await AsyncStorage.setItem(WIDGET_STATE_KEY, JSON.stringify(state));
}
export async function syncWidgetData(): Promise<void> {
if (Platform.OS !== 'android') return;
@ -35,8 +84,7 @@ export async function syncWidgetData(): Promise<void> {
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<void> {
// 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) {

View file

@ -1,73 +1,17 @@
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';
function isWidgetTask(item: unknown): item is WidgetTask {
if (typeof item !== 'object' || item === null) return false;
const obj = item as Record<string, unknown>;
return (
typeof obj.id === 'string' &&
typeof obj.title === 'string' &&
typeof obj.priority === 'number' &&
typeof obj.completed === 'boolean' &&
(obj.dueDate === null || typeof obj.dueDate === 'string') &&
(obj.listColor === null || obj.listColor === undefined || typeof obj.listColor === 'string') &&
(obj.subtaskCount === undefined || typeof obj.subtaskCount === 'number') &&
(obj.subtaskDoneCount === undefined || typeof obj.subtaskDoneCount === 'number')
);
}
async function getWidgetTasks(): Promise<WidgetTask[]> {
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<boolean> {
try {
const data = await AsyncStorage.getItem(WIDGET_DARK_KEY);
if (!data) return false;
return JSON.parse(data) === true;
} catch {
return false;
}
}
async function getExpandedTaskIds(): Promise<Set<string>> {
try {
const data = await AsyncStorage.getItem(WIDGET_EXPANDED_KEY);
if (!data) return new Set();
const parsed: unknown = JSON.parse(data);
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((id): id is string => typeof id === 'string'));
} catch {
return new Set();
}
}
async function setExpandedTaskIds(ids: Set<string>): Promise<void> {
await AsyncStorage.setItem(WIDGET_EXPANDED_KEY, JSON.stringify([...ids]));
}
const EXPAND_DEBOUNCE_MS = 2000;
const lastExpandTimes = new Map<string, number>();
function renderWithState(
renderWidget: WidgetTaskHandlerProps['renderWidget'],
widgetInfo: WidgetTaskHandlerProps['widgetInfo'],
tasks: WidgetTask[],
isDark: boolean,
expandedTaskIds: Set<string>,
expandedTaskIds: string[],
) {
renderWidget(
TaskListWidget({
@ -75,7 +19,7 @@ function renderWithState(
widgetName: widgetInfo.widgetName,
tasks,
isDark,
expandedTaskIds: [...expandedTaskIds],
expandedTaskIds,
})
);
}
@ -89,12 +33,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 +46,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 +64,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 +89,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 +100,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');