fix: consolidate widget AsyncStorage and debounce expand (#29) #31
3 changed files with 109 additions and 116 deletions
32
CLAUDE.md
32
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 "<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue