diff --git a/docs/superpowers/plans/2026-04-15-expo-tasks-etapes.md b/docs/superpowers/plans/2026-04-15-expo-tasks-etapes.md
new file mode 100644
index 0000000..8945eb6
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-15-expo-tasks-etapes.md
@@ -0,0 +1,2303 @@
+# Expo Tasks & Etapes Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add task and etape (stage) management to the Expo mobile app, with a tablo detail screen, task list grouped by etape, task CRUD, and etape CRUD — all backed by the existing Supabase database.
+
+**Architecture:** New tablo detail screen as entry point from tablo list. Tasks fetched directly from Supabase `tasks_with_assignee` view. Etapes are tasks with `is_parent: true`. New React Query hooks mirror the web app pattern. Components use existing theme/styling conventions (useThemeColor, Modal, FlatList with RefreshControl).
+
+**Tech Stack:** Expo Router (file-based routing), React Query v5, Supabase JS client, React Native (Modal, FlatList, Alert), lucide-react-native icons, expo DateTimePicker.
+
+---
+
+## File Structure
+
+### Types
+- **Create:** `xtablo-expo/types/tasks.types.ts` — Task, Etape, TaskInsert, TaskUpdate, TaskStatus types
+
+### Hooks
+- **Create:** `xtablo-expo/hooks/tasks.ts` — useTasksByTablo, useCreateTask, useUpdateTask, useDeleteTask
+- **Create:** `xtablo-expo/hooks/etapes.ts` — useTabloEtapes, useCreateEtape, useUpdateEtape, useDeleteEtape
+- **Create:** `xtablo-expo/hooks/members.ts` — useTabloMembers (read-only)
+
+### Screens
+- **Create:** `xtablo-expo/app/(app)/tablo/_layout.tsx` — Stack layout for tablo routes
+- **Create:** `xtablo-expo/app/(app)/tablo/[id].tsx` — Tablo detail screen with task list
+- **Create:** `xtablo-expo/app/(app)/task/_layout.tsx` — Stack layout for task routes
+- **Create:** `xtablo-expo/app/(app)/task/[id].tsx` — Task create/edit screen
+
+### Components
+- **Create:** `xtablo-expo/components/tasks/TaskList.tsx` — SectionList with etape sections
+- **Create:** `xtablo-expo/components/tasks/TaskRow.tsx` — Single task row
+- **Create:** `xtablo-expo/components/tasks/EtapeSection.tsx` — Collapsible etape header
+- **Create:** `xtablo-expo/components/tasks/EtapeSheet.tsx` — Modal for etape create/edit
+- **Create:** `xtablo-expo/components/tasks/StatusControl.tsx` — Segmented status picker
+- **Create:** `xtablo-expo/components/tasks/AssigneePicker.tsx` — Modal for assignee selection
+- **Create:** `xtablo-expo/components/tasks/EtapePicker.tsx` — Modal for etape selection
+
+### Modified Files
+- **Modify:** `xtablo-expo/types/database.types.ts` — Add tasks table, tasks_with_assignee view, task_status enum
+- **Modify:** `xtablo-expo/app/(app)/_layout.tsx` — Register tablo and task Stack.Screens
+- **Modify:** `xtablo-expo/app/(app)/(tabs)/tablos.tsx` — Navigate to tablo detail on tap instead of channel
+
+---
+
+### Task 1: Update Database Types
+
+**Files:**
+- Modify: `xtablo-expo/types/database.types.ts`
+
+The Expo app's database types file is outdated — it has no `tasks` table, no `tasks_with_assignee` view, and no `task_status` enum. We need to add them.
+
+- [ ] **Step 1: Add task_status enum to Enums section**
+
+Open `xtablo-expo/types/database.types.ts`. Find the `Enums` section (line ~360) which currently only has `devis_status`. Add `task_status` after it:
+
+```typescript
+ Enums: {
+ devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired";
+ task_status: "todo" | "in_progress" | "in_review" | "done";
+ };
+```
+
+- [ ] **Step 2: Add tasks table to Tables section**
+
+In the `Tables` section (after the last existing table, before the closing `};` of Tables), add:
+
+```typescript
+ tasks: {
+ Row: {
+ assignee_id: string | null;
+ created_at: string;
+ deleted_at: string | null;
+ description: string | null;
+ due_date: string | null;
+ id: string;
+ is_parent: boolean;
+ parent_task_id: string | null;
+ position: number;
+ status: Database["public"]["Enums"]["task_status"];
+ tablo_id: string;
+ title: string;
+ updated_at: string;
+ };
+ Insert: {
+ assignee_id?: string | null;
+ created_at?: string;
+ deleted_at?: string | null;
+ description?: string | null;
+ due_date?: string | null;
+ id?: string;
+ is_parent?: boolean;
+ parent_task_id?: string | null;
+ position?: number;
+ status?: Database["public"]["Enums"]["task_status"];
+ tablo_id: string;
+ title: string;
+ updated_at?: string;
+ };
+ Update: {
+ assignee_id?: string | null;
+ created_at?: string;
+ deleted_at?: string | null;
+ description?: string | null;
+ due_date?: string | null;
+ id?: string;
+ is_parent?: boolean;
+ parent_task_id?: string | null;
+ position?: number;
+ status?: Database["public"]["Enums"]["task_status"];
+ tablo_id?: string;
+ title?: string;
+ updated_at?: string;
+ };
+ Relationships: [];
+ };
+```
+
+- [ ] **Step 3: Add tasks_with_assignee view to Views section**
+
+In the `Views` section, add:
+
+```typescript
+ tasks_with_assignee: {
+ Row: {
+ assignee_avatar: string | null;
+ assignee_id: string | null;
+ assignee_name: string | null;
+ created_at: string | null;
+ description: string | null;
+ due_date: string | null;
+ id: string | null;
+ is_parent: boolean | null;
+ parent_task_id: string | null;
+ position: number | null;
+ status: Database["public"]["Enums"]["task_status"] | null;
+ tablo_id: string | null;
+ title: string | null;
+ updated_at: string | null;
+ };
+ Relationships: [];
+ };
+```
+
+- [ ] **Step 4: Verify types compile**
+
+Run: `cd xtablo-expo && npx tsc --noEmit --pretty 2>&1 | head -20`
+Expected: No errors related to tasks types (pre-existing errors are fine).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add xtablo-expo/types/database.types.ts
+git commit -m "feat(expo): add tasks and tasks_with_assignee database types"
+```
+
+---
+
+### Task 2: Create Task and Etape Types
+
+**Files:**
+- Create: `xtablo-expo/types/tasks.types.ts`
+
+- [ ] **Step 1: Create the types file**
+
+Create `xtablo-expo/types/tasks.types.ts`:
+
+```typescript
+import { Database, Tables, TablesInsert, TablesUpdate } from "@/types/database.types";
+import { RemoveNullFromObject } from "@/types/removeNull";
+
+export type TaskStatus = Database["public"]["Enums"]["task_status"];
+
+export type Task = RemoveNullFromObject<
+ Tables<"tasks_with_assignee">,
+ "id" | "tablo_id" | "is_parent" | "title" | "status" | "position" | "created_at" | "updated_at"
+>;
+
+export type Etape = RemoveNullFromObject<
+ Tables<"tasks">,
+ "id" | "tablo_id" | "title" | "is_parent"
+>;
+
+export type TaskInsert = TablesInsert<"tasks">;
+export type TaskUpdate = TablesUpdate<"tasks">;
+
+export const TASK_STATUSES: { value: TaskStatus; label: string; color: string }[] = [
+ { value: "todo", label: "À faire", color: "#3b82f6" },
+ { value: "in_progress", label: "En cours", color: "#eab308" },
+ { value: "in_review", label: "Vérification", color: "#a855f7" },
+ { value: "done", label: "Terminé", color: "#22c55e" },
+];
+```
+
+- [ ] **Step 2: Verify types compile**
+
+Run: `cd xtablo-expo && npx tsc --noEmit --pretty 2>&1 | grep tasks`
+Expected: No errors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add xtablo-expo/types/tasks.types.ts
+git commit -m "feat(expo): add task and etape type definitions"
+```
+
+---
+
+### Task 3: Create Task Hooks
+
+**Files:**
+- Create: `xtablo-expo/hooks/tasks.ts`
+
+- [ ] **Step 1: Create the hooks file**
+
+Create `xtablo-expo/hooks/tasks.ts`:
+
+```typescript
+import { supabase } from "@/lib/supabase";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Task, TaskInsert, TaskUpdate } from "@/types/tasks.types";
+import { Alert } from "react-native";
+
+export const useTasksByTablo = (tabloId: string | undefined) => {
+ return useQuery({
+ queryKey: ["tasks", "tablo", tabloId],
+ queryFn: async () => {
+ const { data, error } = await supabase
+ .from("tasks_with_assignee")
+ .select("*")
+ .eq("tablo_id", tabloId!)
+ .eq("is_parent", false)
+ .order("position", { ascending: true });
+
+ if (error) throw error;
+ return data as Task[];
+ },
+ enabled: !!tabloId,
+ });
+};
+
+export const useCreateTask = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (task: TaskInsert) => {
+ const { data, error } = await supabase
+ .from("tasks")
+ .insert({
+ tablo_id: task.tablo_id,
+ title: task.title,
+ description: task.description ?? null,
+ status: task.status || "todo",
+ assignee_id: task.assignee_id ?? null,
+ position: task.position ?? 0,
+ parent_task_id: task.parent_task_id ?? null,
+ is_parent: false,
+ due_date: task.due_date ?? null,
+ })
+ .select()
+ .single();
+
+ if (error) throw error;
+ return data;
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({ queryKey: ["tasks", "tablo", variables.tablo_id] });
+ },
+ onError: () => {
+ Alert.alert("Erreur", "Impossible de créer la tâche.");
+ },
+ });
+};
+
+export const useUpdateTask = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ id, tabloId, ...updates }: TaskUpdate & { id: string; tabloId: string }) => {
+ const { data, error } = await supabase
+ .from("tasks")
+ .update(updates)
+ .eq("id", id)
+ .select()
+ .single();
+
+ if (error) throw error;
+ return data;
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({ queryKey: ["tasks", "tablo", variables.tabloId] });
+ },
+ onError: () => {
+ Alert.alert("Erreur", "Impossible de modifier la tâche.");
+ },
+ });
+};
+
+export const useDeleteTask = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ id, tabloId }: { id: string; tabloId: string }) => {
+ const { error } = await supabase.from("tasks").delete().eq("id", id);
+ if (error) throw error;
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({ queryKey: ["tasks", "tablo", variables.tabloId] });
+ },
+ onError: () => {
+ Alert.alert("Erreur", "Impossible de supprimer la tâche.");
+ },
+ });
+};
+```
+
+- [ ] **Step 2: Verify types compile**
+
+Run: `cd xtablo-expo && npx tsc --noEmit --pretty 2>&1 | grep "hooks/tasks"`
+Expected: No errors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add xtablo-expo/hooks/tasks.ts
+git commit -m "feat(expo): add task CRUD hooks"
+```
+
+---
+
+### Task 4: Create Etape Hooks
+
+**Files:**
+- Create: `xtablo-expo/hooks/etapes.ts`
+
+- [ ] **Step 1: Create the hooks file**
+
+Create `xtablo-expo/hooks/etapes.ts`:
+
+```typescript
+import { supabase } from "@/lib/supabase";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Etape } from "@/types/tasks.types";
+import { Alert } from "react-native";
+
+export const useTabloEtapes = (tabloId: string | undefined) => {
+ return useQuery({
+ queryKey: ["tablo-etapes", tabloId],
+ queryFn: async () => {
+ const { data, error } = await supabase
+ .from("tasks")
+ .select("*")
+ .eq("tablo_id", tabloId!)
+ .eq("is_parent", true)
+ .is("deleted_at", null)
+ .order("position", { ascending: true });
+
+ if (error) throw error;
+ return data as Etape[];
+ },
+ enabled: !!tabloId,
+ });
+};
+
+export const useCreateEtape = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ tabloId, title, description, position, due_date }: {
+ tabloId: string;
+ title: string;
+ description?: string | null;
+ position?: number;
+ due_date?: string | null;
+ }) => {
+ const { data, error } = await supabase
+ .from("tasks")
+ .insert({
+ tablo_id: tabloId,
+ title,
+ description: description ?? null,
+ position: position ?? 0,
+ is_parent: true,
+ status: "todo",
+ due_date: due_date ?? null,
+ })
+ .select()
+ .single();
+
+ if (error) throw error;
+ return data;
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({ queryKey: ["tablo-etapes", variables.tabloId] });
+ },
+ onError: () => {
+ Alert.alert("Erreur", "Impossible de créer l'étape.");
+ },
+ });
+};
+
+export const useUpdateEtape = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ id, tabloId, ...updates }: {
+ id: string;
+ tabloId: string;
+ title?: string;
+ description?: string | null;
+ position?: number;
+ due_date?: string | null;
+ }) => {
+ const { data, error } = await supabase
+ .from("tasks")
+ .update(updates)
+ .eq("id", id)
+ .select()
+ .single();
+
+ if (error) throw error;
+ return data;
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({ queryKey: ["tablo-etapes", variables.tabloId] });
+ },
+ onError: () => {
+ Alert.alert("Erreur", "Impossible de modifier l'étape.");
+ },
+ });
+};
+
+export const useDeleteEtape = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ id, tabloId }: { id: string; tabloId: string }) => {
+ const { error } = await supabase.from("tasks").delete().eq("id", id);
+ if (error) throw error;
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({ queryKey: ["tablo-etapes", variables.tabloId] });
+ queryClient.invalidateQueries({ queryKey: ["tasks", "tablo", variables.tabloId] });
+ },
+ onError: () => {
+ Alert.alert("Erreur", "Impossible de supprimer l'étape.");
+ },
+ });
+};
+```
+
+- [ ] **Step 2: Verify types compile**
+
+Run: `cd xtablo-expo && npx tsc --noEmit --pretty 2>&1 | grep "hooks/etapes"`
+Expected: No errors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add xtablo-expo/hooks/etapes.ts
+git commit -m "feat(expo): add etape CRUD hooks"
+```
+
+---
+
+### Task 5: Create Members Hook
+
+**Files:**
+- Create: `xtablo-expo/hooks/members.ts`
+
+- [ ] **Step 1: Create the hook file**
+
+The web app fetches members via REST API at `/api/v1/tablos/members/:tabloId`. The Expo app should do the same.
+
+Create `xtablo-expo/hooks/members.ts`:
+
+```typescript
+import { useQuery } from "@tanstack/react-query";
+import { api } from "@/lib/api";
+import { useAuthStore } from "@/stores/auth";
+
+export type TabloMember = {
+ id: string;
+ name: string;
+ is_admin: boolean;
+ email: string;
+ avatar_url: string | null;
+};
+
+export const useTabloMembers = (tabloId: string | undefined) => {
+ const session = useAuthStore((state) => state.session);
+
+ return useQuery({
+ queryKey: ["tablo-members", tabloId],
+ queryFn: async () => {
+ const { data } = await api.get<{ members: TabloMember[] }>(
+ `/api/v1/tablos/members/${tabloId}`,
+ {
+ headers: {
+ Authorization: `Bearer ${session?.access_token}`,
+ },
+ }
+ );
+ return data.members;
+ },
+ enabled: !!tabloId && !!session,
+ });
+};
+```
+
+- [ ] **Step 2: Verify types compile**
+
+Run: `cd xtablo-expo && npx tsc --noEmit --pretty 2>&1 | grep "hooks/members"`
+Expected: No errors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add xtablo-expo/hooks/members.ts
+git commit -m "feat(expo): add tablo members read-only hook"
+```
+
+---
+
+### Task 6: Create StatusControl Component
+
+**Files:**
+- Create: `xtablo-expo/components/tasks/StatusControl.tsx`
+
+- [ ] **Step 1: Create the component**
+
+Create `xtablo-expo/components/tasks/StatusControl.tsx`:
+
+```typescript
+import React from "react";
+import { View, TouchableOpacity, Text, StyleSheet } from "react-native";
+import { TaskStatus, TASK_STATUSES } from "@/types/tasks.types";
+import { useThemeColor } from "@/hooks/useThemeColor";
+
+type StatusControlProps = {
+ value: TaskStatus;
+ onChange: (status: TaskStatus) => void;
+};
+
+export default function StatusControl({ value, onChange }: StatusControlProps) {
+ const bgColor = useThemeColor({ light: "#f1f5f9", dark: "#1f2937" }, "background");
+
+ return (
+
+ {TASK_STATUSES.map((status) => {
+ const isActive = value === status.value;
+ return (
+ onChange(status.value)}
+ >
+
+ {status.label}
+
+
+ );
+ })}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: "row",
+ borderRadius: 10,
+ padding: 3,
+ gap: 2,
+ },
+ segment: {
+ flex: 1,
+ paddingVertical: 8,
+ borderRadius: 8,
+ alignItems: "center",
+ },
+ label: {
+ fontSize: 11,
+ fontWeight: "600",
+ },
+});
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add xtablo-expo/components/tasks/StatusControl.tsx
+git commit -m "feat(expo): add StatusControl segmented picker component"
+```
+
+---
+
+### Task 7: Create TaskRow Component
+
+**Files:**
+- Create: `xtablo-expo/components/tasks/TaskRow.tsx`
+
+- [ ] **Step 1: Create the component**
+
+Create `xtablo-expo/components/tasks/TaskRow.tsx`:
+
+```typescript
+import React from "react";
+import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
+import { Task, TASK_STATUSES } from "@/types/tasks.types";
+import { useThemeColor } from "@/hooks/useThemeColor";
+import { useColorScheme } from "@/hooks/useColorScheme";
+import { Calendar } from "lucide-react-native";
+
+type TaskRowProps = {
+ task: Task;
+ onPress: (task: Task) => void;
+};
+
+export default function TaskRow({ task, onPress }: TaskRowProps) {
+ const colorScheme = useColorScheme();
+ const isDark = colorScheme === "dark";
+ const textColor = useThemeColor({ light: "#1f2937", dark: "#f9fafb" }, "text");
+ const subtextColor = useThemeColor({ light: "#6b7280", dark: "#9ca3af" }, "text");
+ const borderColor = isDark ? "#374151" : "#e5e7eb";
+
+ const statusColor = TASK_STATUSES.find((s) => s.value === task.status)?.color ?? "#9ca3af";
+ const isOverdue = task.due_date && new Date(task.due_date) < new Date();
+
+ return (
+ onPress(task)}
+ activeOpacity={0.7}
+ >
+
+
+
+ {task.title}
+
+
+ {task.due_date && (
+
+
+
+ {new Date(task.due_date).toLocaleDateString("fr-FR", {
+ day: "numeric",
+ month: "short",
+ })}
+
+
+ )}
+
+
+ {task.assignee_name ? (
+
+
+ {task.assignee_name.charAt(0).toUpperCase()}
+
+
+ ) : (
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ gap: 12,
+ },
+ statusDot: {
+ width: 10,
+ height: 10,
+ borderRadius: 5,
+ },
+ content: {
+ flex: 1,
+ gap: 2,
+ },
+ title: {
+ fontSize: 15,
+ fontWeight: "500",
+ },
+ meta: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ },
+ dueDateBadge: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 4,
+ },
+ dueDate: {
+ fontSize: 12,
+ },
+ avatar: {
+ width: 28,
+ height: 28,
+ borderRadius: 14,
+ backgroundColor: "#3b82f6",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ avatarEmpty: {
+ backgroundColor: "transparent",
+ borderWidth: 1.5,
+ borderColor: "#d1d5db",
+ borderStyle: "dashed",
+ },
+ avatarText: {
+ color: "#ffffff",
+ fontSize: 13,
+ fontWeight: "600",
+ },
+});
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add xtablo-expo/components/tasks/TaskRow.tsx
+git commit -m "feat(expo): add TaskRow component"
+```
+
+---
+
+### Task 8: Create EtapeSection Component
+
+**Files:**
+- Create: `xtablo-expo/components/tasks/EtapeSection.tsx`
+
+- [ ] **Step 1: Create the component**
+
+Create `xtablo-expo/components/tasks/EtapeSection.tsx`:
+
+```typescript
+import React, { useState } from "react";
+import { View, Text, TouchableOpacity, StyleSheet, Alert } from "react-native";
+import { ChevronDown, ChevronRight, Plus, MoreHorizontal } from "lucide-react-native";
+import { useThemeColor } from "@/hooks/useThemeColor";
+import { useColorScheme } from "@/hooks/useColorScheme";
+import { Etape } from "@/types/tasks.types";
+
+type EtapeSectionProps = {
+ etape: Etape | null; // null = "Sans Étape"
+ taskCount: number;
+ isCollapsed: boolean;
+ onToggle: () => void;
+ onEdit?: (etape: Etape) => void;
+ onDelete?: (etape: Etape) => void;
+ children: React.ReactNode;
+};
+
+export default function EtapeSection({
+ etape,
+ taskCount,
+ isCollapsed,
+ onToggle,
+ onEdit,
+ onDelete,
+ children,
+}: EtapeSectionProps) {
+ const colorScheme = useColorScheme();
+ const isDark = colorScheme === "dark";
+ const textColor = useThemeColor({ light: "#1f2937", dark: "#f9fafb" }, "text");
+ const bgColor = useThemeColor({ light: "#f1f5f9", dark: "#1e293b" }, "background");
+ const iconColor = isDark ? "#9ca3af" : "#6b7280";
+
+ const title = etape ? etape.title : "Sans Étape";
+ const ChevronIcon = isCollapsed ? ChevronRight : ChevronDown;
+
+ const handleLongPress = () => {
+ if (!etape) return;
+ Alert.alert(etape.title, undefined, [
+ { text: "Modifier", onPress: () => onEdit?.(etape) },
+ {
+ text: "Supprimer",
+ style: "destructive",
+ onPress: () => {
+ Alert.alert(
+ "Supprimer l'étape",
+ "Les tâches associées seront déplacées dans \"Sans Étape\".",
+ [
+ { text: "Annuler", style: "cancel" },
+ { text: "Supprimer", style: "destructive", onPress: () => onDelete?.(etape) },
+ ]
+ );
+ },
+ },
+ { text: "Annuler", style: "cancel" },
+ ]);
+ };
+
+ return (
+
+
+
+
+ {title}
+
+
+ {taskCount}
+
+
+ {!isCollapsed && children}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ header: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingVertical: 10,
+ paddingHorizontal: 16,
+ gap: 8,
+ },
+ title: {
+ flex: 1,
+ fontSize: 14,
+ fontWeight: "700",
+ },
+ badge: {
+ backgroundColor: "#6b7280",
+ borderRadius: 10,
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ },
+ badgeText: {
+ color: "#ffffff",
+ fontSize: 12,
+ fontWeight: "600",
+ },
+});
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add xtablo-expo/components/tasks/EtapeSection.tsx
+git commit -m "feat(expo): add EtapeSection collapsible header component"
+```
+
+---
+
+### Task 9: Create TaskList Component
+
+**Files:**
+- Create: `xtablo-expo/components/tasks/TaskList.tsx`
+
+- [ ] **Step 1: Create the component**
+
+This is the main list component that combines etape sections with task rows.
+
+Create `xtablo-expo/components/tasks/TaskList.tsx`:
+
+```typescript
+import React, { useMemo, useState } from "react";
+import { View, Text, ScrollView, RefreshControl, StyleSheet, TouchableOpacity } from "react-native";
+import { router } from "expo-router";
+import { Plus } from "lucide-react-native";
+import { useThemeColor } from "@/hooks/useThemeColor";
+import { useColorScheme } from "@/hooks/useColorScheme";
+import { useTasksByTablo } from "@/hooks/tasks";
+import { useTabloEtapes } from "@/hooks/etapes";
+import { useDeleteTask } from "@/hooks/tasks";
+import { useDeleteEtape } from "@/hooks/etapes";
+import { Task, Etape, TASK_STATUSES, TaskStatus } from "@/types/tasks.types";
+import EtapeSection from "./EtapeSection";
+import TaskRow from "./TaskRow";
+
+type TaskListProps = {
+ tabloId: string;
+ onEditEtape: (etape: Etape) => void;
+ onCreateEtape: () => void;
+};
+
+type GroupedTasks = {
+ etape: Etape | null;
+ tasks: Task[];
+};
+
+function groupTasksByEtape(tasks: Task[], etapes: Etape[]): GroupedTasks[] {
+ const groups: GroupedTasks[] = [];
+
+ // One section per etape, ordered by position
+ for (const etape of etapes) {
+ const etapeTasks = tasks.filter((t) => t.parent_task_id === etape.id);
+ groups.push({ etape, tasks: etapeTasks });
+ }
+
+ // "Sans Étape" section for orphaned tasks
+ const orphaned = tasks.filter((t) => !t.parent_task_id);
+ if (orphaned.length > 0 || etapes.length === 0) {
+ groups.push({ etape: null, tasks: orphaned });
+ }
+
+ return groups;
+}
+
+function sortTasksByStatus(tasks: Task[]): { status: TaskStatus; label: string; color: string; tasks: Task[] }[] {
+ return TASK_STATUSES
+ .map((s) => ({
+ ...s,
+ tasks: tasks.filter((t) => t.status === s.value),
+ }))
+ .filter((g) => g.tasks.length > 0);
+}
+
+export default function TaskList({ tabloId, onEditEtape, onCreateEtape }: TaskListProps) {
+ const colorScheme = useColorScheme();
+ const isDark = colorScheme === "dark";
+ const textColor = useThemeColor({ light: "#1f2937", dark: "#f9fafb" }, "text");
+ const subtextColor = useThemeColor({ light: "#6b7280", dark: "#9ca3af" }, "text");
+
+ const { data: tasks, isLoading: tasksLoading, refetch: refetchTasks } = useTasksByTablo(tabloId);
+ const { data: etapes, isLoading: etapesLoading, refetch: refetchEtapes } = useTabloEtapes(tabloId);
+ const { mutate: deleteTask } = useDeleteTask();
+ const { mutate: deleteEtape } = useDeleteEtape();
+
+ const [collapsedSections, setCollapsedSections] = useState>(new Set());
+ const [refreshing, setRefreshing] = useState(false);
+
+ const groups = useMemo(
+ () => groupTasksByEtape(tasks ?? [], etapes ?? []),
+ [tasks, etapes]
+ );
+
+ const toggleSection = (key: string) => {
+ setCollapsedSections((prev) => {
+ const next = new Set(prev);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ return next;
+ });
+ };
+
+ const onRefresh = async () => {
+ setRefreshing(true);
+ await Promise.all([refetchTasks(), refetchEtapes()]);
+ setRefreshing(false);
+ };
+
+ const handleTaskPress = (task: Task) => {
+ router.push(`/task/${task.id}?tabloId=${tabloId}`);
+ };
+
+ const handleDeleteEtape = (etape: Etape) => {
+ deleteEtape({ id: etape.id, tabloId });
+ };
+
+ const isLoading = tasksLoading || etapesLoading;
+
+ if (isLoading && !tasks) {
+ return (
+
+ Chargement...
+
+ );
+ }
+
+ return (
+
+ }
+ >
+ {/* Create etape button */}
+
+
+ Nouvelle étape
+
+
+ {groups.map((group) => {
+ const sectionKey = group.etape?.id ?? "no-etape";
+ const isCollapsed = collapsedSections.has(sectionKey);
+ const statusGroups = sortTasksByStatus(group.tasks);
+
+ return (
+ toggleSection(sectionKey)}
+ onEdit={group.etape ? onEditEtape : undefined}
+ onDelete={group.etape ? handleDeleteEtape : undefined}
+ >
+ {statusGroups.map((sg) => (
+
+
+
+
+ {sg.label} ({sg.tasks.length})
+
+
+ {sg.tasks.map((task) => (
+
+ ))}
+
+ ))}
+ {group.tasks.length === 0 && (
+
+ Aucune tâche
+
+ )}
+
+ );
+ })}
+
+ {groups.length === 0 && (
+
+ Aucune tâche
+
+ Créez votre première tâche avec le bouton +
+
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ centered: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ paddingTop: 40,
+ },
+ addEtapeButton: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ },
+ addEtapeText: {
+ color: "#3b82f6",
+ fontSize: 14,
+ fontWeight: "600",
+ },
+ statusHeader: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ paddingHorizontal: 16,
+ paddingVertical: 6,
+ },
+ statusDot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ },
+ statusLabel: {
+ fontSize: 12,
+ fontWeight: "600",
+ textTransform: "uppercase",
+ },
+ emptySection: {
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ },
+ emptyState: {
+ alignItems: "center",
+ paddingTop: 60,
+ gap: 8,
+ },
+ emptyTitle: {
+ fontSize: 17,
+ fontWeight: "600",
+ },
+});
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add xtablo-expo/components/tasks/TaskList.tsx
+git commit -m "feat(expo): add TaskList component with etape grouping"
+```
+
+---
+
+### Task 10: Create EtapeSheet Component
+
+**Files:**
+- Create: `xtablo-expo/components/tasks/EtapeSheet.tsx`
+
+- [ ] **Step 1: Create the component**
+
+This modal is used for creating and editing etapes.
+
+Create `xtablo-expo/components/tasks/EtapeSheet.tsx`:
+
+```typescript
+import React, { useState, useEffect } from "react";
+import {
+ View,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ Modal,
+ KeyboardAvoidingView,
+ Platform,
+ StyleSheet,
+} from "react-native";
+import { X } from "lucide-react-native";
+import { useThemeColor } from "@/hooks/useThemeColor";
+import { useColorScheme } from "@/hooks/useColorScheme";
+import { Etape } from "@/types/tasks.types";
+import { useCreateEtape, useUpdateEtape } from "@/hooks/etapes";
+
+type EtapeSheetProps = {
+ visible: boolean;
+ onClose: () => void;
+ tabloId: string;
+ etape?: Etape | null; // null = create mode
+};
+
+export default function EtapeSheet({ visible, onClose, tabloId, etape }: EtapeSheetProps) {
+ const colorScheme = useColorScheme();
+ const isDark = colorScheme === "dark";
+ const bgColor = useThemeColor({ light: "#ffffff", dark: "#1f2937" }, "background");
+ const textColor = useThemeColor({ light: "#1f2937", dark: "#f9fafb" }, "text");
+ const inputBg = useThemeColor({ light: "#f1f5f9", dark: "#374151" }, "background");
+ const borderColor = isDark ? "#374151" : "#e5e7eb";
+
+ const [title, setTitle] = useState("");
+ const [description, setDescription] = useState("");
+
+ const { mutate: createEtape, isPending: isCreating } = useCreateEtape();
+ const { mutate: updateEtape, isPending: isUpdating } = useUpdateEtape();
+ const isPending = isCreating || isUpdating;
+
+ const isEditMode = !!etape;
+
+ useEffect(() => {
+ if (etape) {
+ setTitle(etape.title);
+ setDescription(etape.description ?? "");
+ } else {
+ setTitle("");
+ setDescription("");
+ }
+ }, [etape, visible]);
+
+ const handleSave = () => {
+ if (!title.trim()) return;
+
+ if (isEditMode && etape) {
+ updateEtape(
+ {
+ id: etape.id,
+ tabloId,
+ title: title.trim(),
+ description: description.trim() || null,
+ },
+ { onSuccess: onClose }
+ );
+ } else {
+ createEtape(
+ {
+ tabloId,
+ title: title.trim(),
+ description: description.trim() || null,
+ },
+ { onSuccess: onClose }
+ );
+ }
+ };
+
+ return (
+
+
+
+
+
+ {isEditMode ? "Modifier l'étape" : "Nouvelle étape"}
+
+
+
+
+
+
+
+ Titre
+
+
+ Description
+
+
+
+
+
+ {isPending ? "..." : isEditMode ? "Enregistrer" : "Créer"}
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ justifyContent: "flex-end",
+ backgroundColor: "rgba(0,0,0,0.4)",
+ },
+ sheet: {
+ borderTopLeftRadius: 20,
+ borderTopRightRadius: 20,
+ paddingBottom: 34,
+ },
+ sheetHeader: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ padding: 16,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ },
+ sheetTitle: {
+ fontSize: 17,
+ fontWeight: "700",
+ },
+ sheetBody: {
+ padding: 16,
+ gap: 12,
+ },
+ label: {
+ fontSize: 14,
+ fontWeight: "600",
+ },
+ input: {
+ borderRadius: 10,
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ fontSize: 15,
+ },
+ textArea: {
+ minHeight: 80,
+ textAlignVertical: "top",
+ },
+ saveButton: {
+ marginHorizontal: 16,
+ backgroundColor: "#3b82f6",
+ borderRadius: 12,
+ paddingVertical: 14,
+ alignItems: "center",
+ },
+ saveButtonDisabled: {
+ opacity: 0.5,
+ },
+ saveButtonText: {
+ color: "#ffffff",
+ fontSize: 16,
+ fontWeight: "700",
+ },
+});
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add xtablo-expo/components/tasks/EtapeSheet.tsx
+git commit -m "feat(expo): add EtapeSheet modal for etape create/edit"
+```
+
+---
+
+### Task 11: Create AssigneePicker and EtapePicker Components
+
+**Files:**
+- Create: `xtablo-expo/components/tasks/AssigneePicker.tsx`
+- Create: `xtablo-expo/components/tasks/EtapePicker.tsx`
+
+- [ ] **Step 1: Create AssigneePicker**
+
+Create `xtablo-expo/components/tasks/AssigneePicker.tsx`:
+
+```typescript
+import React from "react";
+import {
+ View,
+ Text,
+ TouchableOpacity,
+ Modal,
+ FlatList,
+ StyleSheet,
+} from "react-native";
+import { X, Check } from "lucide-react-native";
+import { useThemeColor } from "@/hooks/useThemeColor";
+import { useColorScheme } from "@/hooks/useColorScheme";
+import { TabloMember } from "@/hooks/members";
+
+type AssigneePickerProps = {
+ visible: boolean;
+ onClose: () => void;
+ members: TabloMember[];
+ selectedId: string | null;
+ onSelect: (memberId: string | null) => void;
+};
+
+export default function AssigneePicker({
+ visible,
+ onClose,
+ members,
+ selectedId,
+ onSelect,
+}: AssigneePickerProps) {
+ const colorScheme = useColorScheme();
+ const isDark = colorScheme === "dark";
+ const bgColor = useThemeColor({ light: "#ffffff", dark: "#1f2937" }, "background");
+ const textColor = useThemeColor({ light: "#1f2937", dark: "#f9fafb" }, "text");
+ const subtextColor = useThemeColor({ light: "#6b7280", dark: "#9ca3af" }, "text");
+ const borderColor = isDark ? "#374151" : "#e5e7eb";
+
+ const handleSelect = (id: string | null) => {
+ onSelect(id);
+ onClose();
+ };
+
+ return (
+
+
+
+
+ Assigné à
+
+
+
+
+
+ handleSelect(null)}
+ >
+
+ Non assigné
+ {selectedId === null && }
+
+
+ item.id}
+ renderItem={({ item }) => (
+ handleSelect(item.id)}
+ >
+
+
+ {item.name.charAt(0).toUpperCase()}
+
+
+ {item.name}
+ {selectedId === item.id && }
+
+ )}
+ />
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ justifyContent: "flex-end",
+ backgroundColor: "rgba(0,0,0,0.4)",
+ },
+ sheet: {
+ borderTopLeftRadius: 20,
+ borderTopRightRadius: 20,
+ maxHeight: "60%",
+ paddingBottom: 34,
+ },
+ header: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ padding: 16,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ },
+ title: {
+ fontSize: 17,
+ fontWeight: "700",
+ },
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ gap: 12,
+ },
+ avatar: {
+ width: 32,
+ height: 32,
+ borderRadius: 16,
+ backgroundColor: "#3b82f6",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ avatarEmpty: {
+ backgroundColor: "transparent",
+ borderWidth: 1.5,
+ borderColor: "#d1d5db",
+ borderStyle: "dashed",
+ },
+ avatarText: {
+ color: "#ffffff",
+ fontSize: 14,
+ fontWeight: "600",
+ },
+ name: {
+ flex: 1,
+ fontSize: 15,
+ },
+});
+```
+
+- [ ] **Step 2: Create EtapePicker**
+
+Create `xtablo-expo/components/tasks/EtapePicker.tsx`:
+
+```typescript
+import React from "react";
+import {
+ View,
+ Text,
+ TouchableOpacity,
+ Modal,
+ FlatList,
+ StyleSheet,
+} from "react-native";
+import { X, Check } from "lucide-react-native";
+import { useThemeColor } from "@/hooks/useThemeColor";
+import { useColorScheme } from "@/hooks/useColorScheme";
+import { Etape } from "@/types/tasks.types";
+
+type EtapePickerProps = {
+ visible: boolean;
+ onClose: () => void;
+ etapes: Etape[];
+ selectedId: string | null;
+ onSelect: (etapeId: string | null) => void;
+};
+
+export default function EtapePicker({
+ visible,
+ onClose,
+ etapes,
+ selectedId,
+ onSelect,
+}: EtapePickerProps) {
+ const colorScheme = useColorScheme();
+ const isDark = colorScheme === "dark";
+ const bgColor = useThemeColor({ light: "#ffffff", dark: "#1f2937" }, "background");
+ const textColor = useThemeColor({ light: "#1f2937", dark: "#f9fafb" }, "text");
+ const subtextColor = useThemeColor({ light: "#6b7280", dark: "#9ca3af" }, "text");
+ const borderColor = isDark ? "#374151" : "#e5e7eb";
+
+ const handleSelect = (id: string | null) => {
+ onSelect(id);
+ onClose();
+ };
+
+ return (
+
+
+
+
+ Étape
+
+
+
+
+
+ handleSelect(null)}
+ >
+ Sans Étape
+ {selectedId === null && }
+
+
+ item.id}
+ renderItem={({ item }) => (
+ handleSelect(item.id)}
+ >
+ {item.title}
+ {selectedId === item.id && }
+
+ )}
+ />
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ justifyContent: "flex-end",
+ backgroundColor: "rgba(0,0,0,0.4)",
+ },
+ sheet: {
+ borderTopLeftRadius: 20,
+ borderTopRightRadius: 20,
+ maxHeight: "50%",
+ paddingBottom: 34,
+ },
+ header: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ padding: 16,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ },
+ title: {
+ fontSize: 17,
+ fontWeight: "700",
+ },
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingVertical: 14,
+ paddingHorizontal: 16,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ },
+ name: {
+ flex: 1,
+ fontSize: 15,
+ },
+});
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add xtablo-expo/components/tasks/AssigneePicker.tsx xtablo-expo/components/tasks/EtapePicker.tsx
+git commit -m "feat(expo): add AssigneePicker and EtapePicker modal components"
+```
+
+---
+
+### Task 12: Create Tablo Detail Screen
+
+**Files:**
+- Create: `xtablo-expo/app/(app)/tablo/_layout.tsx`
+- Create: `xtablo-expo/app/(app)/tablo/[id].tsx`
+- Modify: `xtablo-expo/app/(app)/_layout.tsx` — Register tablo Stack.Screen
+
+- [ ] **Step 1: Create tablo layout**
+
+Create `xtablo-expo/app/(app)/tablo/_layout.tsx`:
+
+```typescript
+import { Stack } from "expo-router";
+
+export default function TabloLayout() {
+ return ;
+}
+```
+
+- [ ] **Step 2: Create tablo detail screen**
+
+Create `xtablo-expo/app/(app)/tablo/[id].tsx`:
+
+```typescript
+import React, { useState } from "react";
+import { View, Text, TouchableOpacity, StyleSheet, SafeAreaView } from "react-native";
+import { useLocalSearchParams, router } from "expo-router";
+import { ArrowLeft, Plus } from "lucide-react-native";
+import { useThemeColor } from "@/hooks/useThemeColor";
+import { useColorScheme } from "@/hooks/useColorScheme";
+import { useTablosList } from "@/hooks/tablos";
+import { Etape } from "@/types/tasks.types";
+import { ColorMap } from "@/constants/colors";
+import TaskList from "@/components/tasks/TaskList";
+import EtapeSheet from "@/components/tasks/EtapeSheet";
+
+export default function TabloDetailScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const colorScheme = useColorScheme();
+ const isDark = colorScheme === "dark";
+ const bgColor = useThemeColor({ light: "#f8fafc", dark: "#111827" }, "background");
+ const textColor = useThemeColor({ light: "#1f2937", dark: "#f9fafb" }, "text");
+ const headerBg = useThemeColor({ light: "#ffffff", dark: "#1f2937" }, "background");
+ const borderColor = isDark ? "#374151" : "#e5e7eb";
+
+ const { data: tablos } = useTablosList();
+ const tablo = tablos?.find((t) => t.id === id);
+
+ const [etapeSheetVisible, setEtapeSheetVisible] = useState(false);
+ const [editingEtape, setEditingEtape] = useState(null);
+
+ const handleEditEtape = (etape: Etape) => {
+ setEditingEtape(etape);
+ setEtapeSheetVisible(true);
+ };
+
+ const handleCreateEtape = () => {
+ setEditingEtape(null);
+ setEtapeSheetVisible(true);
+ };
+
+ const handleCloseEtapeSheet = () => {
+ setEtapeSheetVisible(false);
+ setEditingEtape(null);
+ };
+
+ if (!tablo || !id) {
+ return (
+
+ Tablo introuvable
+
+ );
+ }
+
+ const tabloColor = tablo.color ? ColorMap[tablo.color] ?? "#3b82f6" : "#3b82f6";
+
+ return (
+
+ {/* Header */}
+
+ router.back()} style={styles.backButton}>
+
+
+
+
+ {tablo.name}
+
+
+
+ {/* Task list */}
+
+
+ {/* FAB - create task */}
+ router.push(`/task/new?tabloId=${id}`)}
+ >
+
+
+
+ {/* Etape sheet */}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ header: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingHorizontal: 12,
+ paddingVertical: 14,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ gap: 10,
+ },
+ backButton: {
+ padding: 4,
+ },
+ colorIndicator: {
+ width: 14,
+ height: 14,
+ borderRadius: 4,
+ },
+ tabloName: {
+ flex: 1,
+ fontSize: 18,
+ fontWeight: "700",
+ },
+ fab: {
+ position: "absolute",
+ bottom: 24,
+ right: 20,
+ width: 54,
+ height: 54,
+ borderRadius: 27,
+ backgroundColor: "#3b82f6",
+ alignItems: "center",
+ justifyContent: "center",
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.2,
+ shadowRadius: 6,
+ elevation: 6,
+ },
+});
+```
+
+- [ ] **Step 3: Register tablo route in app layout**
+
+Open `xtablo-expo/app/(app)/_layout.tsx` and add the tablo Stack.Screen. Change from:
+
+```typescript
+
+
+
+
+```
+
+To:
+
+```typescript
+
+
+
+
+
+
+```
+
+- [ ] **Step 4: Verify it compiles**
+
+Run: `cd xtablo-expo && npx tsc --noEmit --pretty 2>&1 | head -20`
+Expected: No new errors.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add xtablo-expo/app/(app)/tablo/ xtablo-expo/app/(app)/_layout.tsx
+git commit -m "feat(expo): add tablo detail screen with task list and etape management"
+```
+
+---
+
+### Task 13: Create Task Detail Screen
+
+**Files:**
+- Create: `xtablo-expo/app/(app)/task/_layout.tsx`
+- Create: `xtablo-expo/app/(app)/task/[id].tsx`
+
+- [ ] **Step 1: Create task layout**
+
+Create `xtablo-expo/app/(app)/task/_layout.tsx`:
+
+```typescript
+import { Stack } from "expo-router";
+
+export default function TaskLayout() {
+ return ;
+}
+```
+
+- [ ] **Step 2: Create task detail screen**
+
+Create `xtablo-expo/app/(app)/task/[id].tsx`:
+
+```typescript
+import React, { useState, useEffect } from "react";
+import {
+ View,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ ScrollView,
+ StyleSheet,
+ SafeAreaView,
+ Alert,
+ Platform,
+} from "react-native";
+import { useLocalSearchParams, router } from "expo-router";
+import { ArrowLeft, User, Layers, Calendar } from "lucide-react-native";
+import DateTimePicker from "@react-native-community/datetimepicker";
+import { useThemeColor } from "@/hooks/useThemeColor";
+import { useColorScheme } from "@/hooks/useColorScheme";
+import { useTasksByTablo, useCreateTask, useUpdateTask, useDeleteTask } from "@/hooks/tasks";
+import { useTabloEtapes } from "@/hooks/etapes";
+import { useTabloMembers } from "@/hooks/members";
+import { TaskStatus, TASK_STATUSES } from "@/types/tasks.types";
+import StatusControl from "@/components/tasks/StatusControl";
+import AssigneePicker from "@/components/tasks/AssigneePicker";
+import EtapePicker from "@/components/tasks/EtapePicker";
+
+export default function TaskDetailScreen() {
+ const { id, tabloId } = useLocalSearchParams<{ id: string; tabloId: string }>();
+ const isCreateMode = id === "new";
+
+ const colorScheme = useColorScheme();
+ const isDark = colorScheme === "dark";
+ const bgColor = useThemeColor({ light: "#f8fafc", dark: "#111827" }, "background");
+ const textColor = useThemeColor({ light: "#1f2937", dark: "#f9fafb" }, "text");
+ const headerBg = useThemeColor({ light: "#ffffff", dark: "#1f2937" }, "background");
+ const inputBg = useThemeColor({ light: "#f1f5f9", dark: "#374151" }, "background");
+ const borderColor = isDark ? "#374151" : "#e5e7eb";
+ const subtextColor = useThemeColor({ light: "#6b7280", dark: "#9ca3af" }, "text");
+
+ // Data
+ const { data: tasks } = useTasksByTablo(tabloId);
+ const { data: etapes } = useTabloEtapes(tabloId);
+ const { data: members } = useTabloMembers(tabloId);
+ const existingTask = isCreateMode ? null : tasks?.find((t) => t.id === id);
+
+ // Form state
+ const [title, setTitle] = useState("");
+ const [description, setDescription] = useState("");
+ const [status, setStatus] = useState("todo");
+ const [assigneeId, setAssigneeId] = useState(null);
+ const [parentTaskId, setParentTaskId] = useState(null);
+ const [dueDate, setDueDate] = useState(null);
+ const [showDatePicker, setShowDatePicker] = useState(false);
+
+ // Picker visibility
+ const [assigneePickerVisible, setAssigneePickerVisible] = useState(false);
+ const [etapePickerVisible, setEtapePickerVisible] = useState(false);
+
+ // Mutations
+ const { mutate: createTask, isPending: isCreating } = useCreateTask();
+ const { mutate: updateTask, isPending: isUpdating } = useUpdateTask();
+ const { mutate: deleteTask, isPending: isDeleting } = useDeleteTask();
+ const isPending = isCreating || isUpdating || isDeleting;
+
+ // Populate form for edit mode
+ useEffect(() => {
+ if (existingTask) {
+ setTitle(existingTask.title);
+ setDescription(existingTask.description ?? "");
+ setStatus(existingTask.status);
+ setAssigneeId(existingTask.assignee_id);
+ setParentTaskId(existingTask.parent_task_id);
+ setDueDate(existingTask.due_date ? new Date(existingTask.due_date) : null);
+ }
+ }, [existingTask]);
+
+ const handleSave = () => {
+ if (!title.trim() || !tabloId) return;
+
+ const dueDateStr = dueDate
+ ? `${dueDate.getFullYear()}-${String(dueDate.getMonth() + 1).padStart(2, "0")}-${String(dueDate.getDate()).padStart(2, "0")}`
+ : null;
+
+ if (isCreateMode) {
+ createTask(
+ {
+ tablo_id: tabloId,
+ title: title.trim(),
+ description: description.trim() || null,
+ status,
+ assignee_id: assigneeId,
+ parent_task_id: parentTaskId,
+ due_date: dueDateStr,
+ },
+ { onSuccess: () => router.back() }
+ );
+ } else {
+ updateTask(
+ {
+ id: id!,
+ tabloId: tabloId!,
+ title: title.trim(),
+ description: description.trim() || null,
+ status,
+ assignee_id: assigneeId,
+ parent_task_id: parentTaskId,
+ due_date: dueDateStr,
+ },
+ { onSuccess: () => router.back() }
+ );
+ }
+ };
+
+ const handleDelete = () => {
+ Alert.alert("Supprimer la tâche", "Cette action est irréversible.", [
+ { text: "Annuler", style: "cancel" },
+ {
+ text: "Supprimer",
+ style: "destructive",
+ onPress: () => {
+ deleteTask({ id: id!, tabloId: tabloId! }, { onSuccess: () => router.back() });
+ },
+ },
+ ]);
+ };
+
+ // Derived display values
+ const assigneeName = members?.find((m) => m.id === assigneeId)?.name ?? "Non assigné";
+ const etapeName = etapes?.find((e) => e.id === parentTaskId)?.title ?? "Sans Étape";
+ const dueDateDisplay = dueDate
+ ? dueDate.toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" })
+ : "Aucune";
+
+ return (
+
+ {/* Header */}
+
+ router.back()} style={styles.backButton}>
+
+
+
+ {isCreateMode ? "Nouvelle tâche" : "Modifier la tâche"}
+
+
+
+
+ {/* Title */}
+ Titre
+
+
+ {/* Description */}
+ Description
+
+
+ {/* Status */}
+ Statut
+
+
+ {/* Assignee */}
+ Assigné à
+ setAssigneePickerVisible(true)}
+ >
+
+
+ {assigneeName}
+
+
+
+ {/* Etape */}
+ Étape
+ setEtapePickerVisible(true)}
+ >
+
+
+ {etapeName}
+
+
+
+ {/* Due date */}
+ Date limite
+ setShowDatePicker(true)}
+ >
+
+
+ {dueDateDisplay}
+
+ {dueDate && (
+ setDueDate(null)}>
+ Retirer
+
+ )}
+
+
+ {showDatePicker && (
+ {
+ setShowDatePicker(Platform.OS !== "ios");
+ if (date) setDueDate(date);
+ }}
+ />
+ )}
+
+ {/* Save button */}
+
+
+ {isPending ? "..." : isCreateMode ? "Créer" : "Enregistrer"}
+
+
+
+ {/* Delete button (edit mode only) */}
+ {!isCreateMode && (
+
+ Supprimer la tâche
+
+ )}
+
+
+
+
+ {/* Pickers */}
+ setAssigneePickerVisible(false)}
+ members={members ?? []}
+ selectedId={assigneeId}
+ onSelect={setAssigneeId}
+ />
+ setEtapePickerVisible(false)}
+ etapes={etapes ?? []}
+ selectedId={parentTaskId}
+ onSelect={setParentTaskId}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ header: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingHorizontal: 12,
+ paddingVertical: 14,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ gap: 10,
+ },
+ backButton: {
+ padding: 4,
+ },
+ headerTitle: {
+ fontSize: 18,
+ fontWeight: "700",
+ },
+ form: {
+ flex: 1,
+ padding: 16,
+ },
+ label: {
+ fontSize: 14,
+ fontWeight: "600",
+ marginTop: 16,
+ marginBottom: 6,
+ },
+ input: {
+ borderRadius: 10,
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ fontSize: 15,
+ },
+ textArea: {
+ minHeight: 100,
+ textAlignVertical: "top",
+ },
+ pickerButton: {
+ flexDirection: "row",
+ alignItems: "center",
+ borderRadius: 10,
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ gap: 10,
+ },
+ pickerText: {
+ flex: 1,
+ fontSize: 15,
+ },
+ saveButton: {
+ backgroundColor: "#3b82f6",
+ borderRadius: 12,
+ paddingVertical: 14,
+ alignItems: "center",
+ marginTop: 24,
+ },
+ saveButtonDisabled: {
+ opacity: 0.5,
+ },
+ saveButtonText: {
+ color: "#ffffff",
+ fontSize: 16,
+ fontWeight: "700",
+ },
+ deleteButton: {
+ borderRadius: 12,
+ paddingVertical: 14,
+ alignItems: "center",
+ marginTop: 12,
+ },
+ deleteButtonText: {
+ color: "#ef4444",
+ fontSize: 15,
+ fontWeight: "600",
+ },
+});
+```
+
+- [ ] **Step 3: Verify it compiles**
+
+Run: `cd xtablo-expo && npx tsc --noEmit --pretty 2>&1 | head -20`
+Expected: No new errors. Note: `@react-native-community/datetimepicker` may need to be installed — see Task 14.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add xtablo-expo/app/(app)/task/
+git commit -m "feat(expo): add task detail screen with create/edit/delete"
+```
+
+---
+
+### Task 14: Install DateTimePicker Dependency
+
+**Files:**
+- Modify: `xtablo-expo/package.json`
+
+The task detail screen uses `@react-native-community/datetimepicker` for native date picking. Check if it's already installed.
+
+- [ ] **Step 1: Check if already installed**
+
+Run: `cd xtablo-expo && grep datetimepicker package.json`
+Expected: Either a match (already installed) or no output (needs install).
+
+- [ ] **Step 2: Install if missing**
+
+If not installed, run:
+```bash
+cd xtablo-expo && npx expo install @react-native-community/datetimepicker
+```
+
+- [ ] **Step 3: Commit if changed**
+
+```bash
+git add xtablo-expo/package.json xtablo-expo/yarn.lock xtablo-expo/package-lock.json 2>/dev/null
+git commit -m "chore(expo): add @react-native-community/datetimepicker dependency"
+```
+
+---
+
+### Task 15: Update Tablos List Navigation
+
+**Files:**
+- Modify: `xtablo-expo/app/(app)/(tabs)/tablos.tsx`
+
+Currently tapping a tablo navigates to the chat channel. Change it to navigate to the new tablo detail screen.
+
+- [ ] **Step 1: Find the navigation handler**
+
+Open `xtablo-expo/app/(app)/(tabs)/tablos.tsx`. Find the `navigateToTablo` function or the `onPress` handler for tablo items. It currently does something like:
+
+```typescript
+router.push(`/channel/${getChannelCid(tablo.id)}`);
+```
+
+- [ ] **Step 2: Change navigation target**
+
+Replace the tablo tap handler to navigate to the tablo detail screen instead:
+
+```typescript
+const navigateToTablo = (tablo: UserTablo) => {
+ router.push(`/tablo/${tablo.id}`);
+};
+```
+
+Keep any existing "Chat" quick action button that navigates to the channel — just change the main tablo tap target.
+
+- [ ] **Step 3: Verify the app runs**
+
+Run: `cd xtablo-expo && npx expo start`
+Open on simulator/device. Tap a tablo — should navigate to the new tablo detail screen.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add xtablo-expo/app/(app)/(tabs)/tablos.tsx
+git commit -m "feat(expo): navigate to tablo detail on tap instead of channel"
+```
+
+---
+
+### Task 16: End-to-End Smoke Test
+
+No new files. This is a manual verification task.
+
+- [ ] **Step 1: Start the app**
+
+Run: `cd xtablo-expo && npx expo start`
+
+- [ ] **Step 2: Test tablo detail**
+
+- Open the app
+- Tap a tablo → should see tablo detail screen with header and task list
+- Verify back button returns to tablos list
+
+- [ ] **Step 3: Test task creation**
+
+- Tap the "+" FAB on tablo detail
+- Fill in title, set status, pick assignee/etape/due date
+- Tap "Créer"
+- Verify the task appears in the task list under the correct etape section
+
+- [ ] **Step 4: Test task editing**
+
+- Tap an existing task
+- Change title, status, assignee
+- Tap "Enregistrer"
+- Verify changes are reflected in the list
+
+- [ ] **Step 5: Test task deletion**
+
+- Tap a task to edit
+- Tap "Supprimer la tâche"
+- Confirm deletion
+- Verify task is removed from the list
+
+- [ ] **Step 6: Test etape management**
+
+- Tap "Nouvelle étape" on the task list
+- Create an etape with title
+- Verify it appears as a section header
+- Long-press the etape header → Edit → change title → save
+- Long-press the etape header → Delete → confirm
+- Verify child tasks move to "Sans Étape"
+
+- [ ] **Step 7: Test pull-to-refresh**
+
+- Pull down on the task list
+- Verify data refreshes
+
+- [ ] **Step 8: Test collapse/expand**
+
+- Tap an etape section header
+- Verify tasks collapse
+- Tap again → verify they expand