- 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>
This commit is contained in:
parent
2412d368ac
commit
117de533d7
6 changed files with 430 additions and 118 deletions
74
CLAUDE.md
74
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é.
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export default function TaskDetailScreen() {
|
|||
const [selectedListId, setSelectedListId] = useState<string>('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const subtaskInputY = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isValidUUID(id)) {
|
||||
|
|
@ -395,13 +396,16 @@ export default function TaskDetailScreen() {
|
|||
))}
|
||||
|
||||
{/* Add subtask */}
|
||||
<View className="mt-2 flex-row items-center">
|
||||
<View
|
||||
className="mt-2 flex-row items-center"
|
||||
onLayout={(e) => { subtaskInputY.current = e.nativeEvent.layout.y; }}
|
||||
>
|
||||
<Plus size={18} color={colors.bleu.DEFAULT} />
|
||||
<TextInput
|
||||
value={newSubtask}
|
||||
onChangeText={setNewSubtask}
|
||||
onSubmitEditing={handleAddSubtask}
|
||||
onFocus={() => 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]'}`}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export default function NewTaskScreen() {
|
|||
const [pendingSubtasks, setPendingSubtasks] = useState<string[]>([]);
|
||||
const [newSubtask, setNewSubtask] = useState('');
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const subtaskInputY = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
getAllLists().then(setLists);
|
||||
|
|
@ -365,13 +366,16 @@ export default function NewTaskScreen() {
|
|||
))}
|
||||
|
||||
{/* Add subtask */}
|
||||
<View className="mt-2 flex-row items-center">
|
||||
<View
|
||||
className="mt-2 flex-row items-center"
|
||||
onLayout={(e) => { subtaskInputY.current = e.nativeEvent.layout.y; }}
|
||||
>
|
||||
<Plus size={18} color={colors.bleu.DEFAULT} />
|
||||
<TextInput
|
||||
value={newSubtask}
|
||||
onChangeText={setNewSubtask}
|
||||
onSubmitEditing={handleAddPendingSubtask}
|
||||
onFocus={() => 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]'}`}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
|
|
@ -93,6 +100,7 @@ export async function syncWidgetData(): Promise<void> {
|
|||
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<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.position));
|
||||
task.subtasks = subs;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine dark mode from settings
|
||||
let isDark = false;
|
||||
try {
|
||||
|
|
@ -122,6 +142,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) {
|
||||
|
|
@ -129,7 +161,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 {
|
||||
|
|
|
|||
|
|
@ -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<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 ? (
|
||||
<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,
|
||||
}}
|
||||
/>
|
||||
{(task.subtaskCount ?? 0) > 0 ? (
|
||||
<TextWidget
|
||||
text={`✓ ${task.subtaskDoneCount ?? 0}/${task.subtaskCount}`}
|
||||
{/* 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={{
|
||||
fontSize: 11,
|
||||
fontFamily: FONT_REGULAR,
|
||||
color: task.subtaskDoneCount === task.subtaskCount ? TODAY_COLOR : c.textSecondary,
|
||||
marginTop: 1,
|
||||
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: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 4,
|
||||
}}
|
||||
clickAction="TOGGLE_EXPAND"
|
||||
clickActionData={{ taskId: task.id }}
|
||||
>
|
||||
<TextWidget
|
||||
text={isExpanded ? '▾' : '▸'}
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontFamily: FONT_SEMIBOLD,
|
||||
color: TODAY_COLOR,
|
||||
}}
|
||||
/>
|
||||
</FlexWidget>
|
||||
) : null}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -260,10 +381,12 @@ 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);
|
||||
|
|
@ -337,7 +460,12 @@ function ListWidgetContent({
|
|||
}}
|
||||
>
|
||||
{displayTasks.map((task) => (
|
||||
<TaskItemRow key={task.id} task={task} isDark={isDark} />
|
||||
<TaskItemRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
isDark={isDark}
|
||||
isExpanded={expandedTaskIds.has(task.id)}
|
||||
/>
|
||||
))}
|
||||
</FlexWidget>
|
||||
) : (
|
||||
|
|
@ -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 <SmallWidget tasks={widgetTasks} isDark={isDark} />;
|
||||
}
|
||||
|
||||
const maxItems = widgetName === 'SimplListeLarge' ? 8 : 4;
|
||||
return <ListWidgetContent tasks={widgetTasks} maxItems={maxItems} isDark={isDark} />;
|
||||
return (
|
||||
<ListWidgetContent
|
||||
tasks={widgetTasks}
|
||||
maxItems={maxItems}
|
||||
isDark={isDark}
|
||||
expandedTaskIds={expandedTaskIds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -41,6 +43,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> {
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue