import { router, Stack } from "expo-router"; import { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, Alert, FlatList, Text, TextInput, TouchableOpacity, View, } from "react-native"; import ProtectedScreen from "./components/ProtectedScreen"; import { useAuth } from "./lib/nhost/AuthProvider"; import { commonStyles } from "./styles/commonStyles"; import { colors } from "./styles/theme"; // The interfaces below define the structure of our data // They are not strictly necessary but help with type safety // Represents a single todo item interface Todo { id: string; title: string; details: string | null; completed: boolean; created_at: string; updated_at: string; user_id: string; } // This matches the GraphQL response structure for fetching todos // Can be used as a generic type on the request method interface GetTodos { todos: Todo[]; } // This matches the GraphQL response structure for inserting a todo // Can be used as a generic type on the request method interface InsertTodo { insert_todos_one: Todo | null; } // This matches the GraphQL response structure for updating a todo // Can be used as a generic type on the request method interface UpdateTodo { update_todos_by_pk: Todo | null; } export default function Todos() { const { nhost, session } = useAuth(); const [todos, setTodos] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [newTodoTitle, setNewTodoTitle] = useState(""); const [newTodoDetails, setNewTodoDetails] = useState(""); const [editingTodo, setEditingTodo] = useState(null); const [showAddForm, setShowAddForm] = useState(false); const [expandedTodos, setExpandedTodos] = useState>(new Set()); const [addingTodo, setAddingTodo] = useState(false); const [updatingTodos, setUpdatingTodos] = useState>(new Set()); // Redirect to sign in if not authenticated useEffect(() => { if (!session) { router.replace("/signin"); } }, [session]); const fetchTodos = useCallback(async () => { try { setLoading(true); // Make GraphQL request to fetch todos using Nhost client // The query automatically filters by user_id due to Hasura permissions const response = await nhost.graphql.request({ query: ` query GetTodos { todos(order_by: { created_at: desc }) { id title details completed created_at updated_at user_id } } `, }); // Check for GraphQL errors in the response body if (response.body.errors) { throw new Error( response.body.errors[0]?.message || "Failed to fetch todos", ); } // Extract todos from the GraphQL response data setTodos(response.body?.data?.todos || []); setError(null); } catch (err) { setError(err instanceof Error ? err.message : "Failed to fetch todos"); } finally { setLoading(false); } }, [nhost.graphql]); const addTodo = async () => { if (!newTodoTitle.trim()) return; try { setAddingTodo(true); // Execute GraphQL mutation to insert a new todo // user_id is automatically set by Hasura based on JWT token const response = await nhost.graphql.request({ query: ` mutation InsertTodo($title: String!, $details: String) { insert_todos_one(object: { title: $title, details: $details }) { id title details completed created_at updated_at user_id } } `, variables: { title: newTodoTitle.trim(), details: newTodoDetails.trim() || null, }, }); if (response.body.errors) { throw new Error( response.body.errors[0]?.message || "Failed to add todo", ); } if (!response.body?.data?.insert_todos_one) { throw new Error("Failed to add todo"); } setTodos([response.body?.data?.insert_todos_one, ...todos]); setNewTodoTitle(""); setNewTodoDetails(""); setShowAddForm(false); setError(null); } catch (err) { setError(err instanceof Error ? err.message : "Failed to add todo"); Alert.alert( "Error", err instanceof Error ? err.message : "Failed to add todo", ); } finally { setAddingTodo(false); } }; const updateTodo = async ( id: string, updates: Partial>, ) => { try { setUpdatingTodos((prev) => new Set([...prev, id])); // Execute GraphQL mutation to update an existing todo by primary key // Hasura permissions ensure users can only update their own todos const response = await nhost.graphql.request({ query: ` mutation UpdateTodo($id: uuid!, $updates: todos_set_input!) { update_todos_by_pk(pk_columns: { id: $id }, _set: $updates) { id title details completed created_at updated_at user_id } } `, variables: { id, updates, }, }); if (response.body.errors) { throw new Error( response.body.errors[0]?.message || "Failed to update todo", ); } if (!response.body?.data?.update_todos_by_pk) { throw new Error("Failed to update todo"); } const updatedTodo = response.body?.data?.update_todos_by_pk; if (updatedTodo) { setTodos(todos.map((todo) => (todo.id === id ? updatedTodo : todo))); } setEditingTodo(null); setError(null); } catch (err) { setError(err instanceof Error ? err.message : "Failed to update todo"); Alert.alert( "Error", err instanceof Error ? err.message : "Failed to update todo", ); } finally { setUpdatingTodos((prev) => { const newSet = new Set(prev); newSet.delete(id); return newSet; }); } }; const deleteTodo = async (id: string) => { Alert.alert("Delete Todo", "Are you sure you want to delete this todo?", [ { text: "Cancel", style: "cancel" }, { text: "Delete", style: "destructive", onPress: async () => { try { setUpdatingTodos((prev) => new Set([...prev, id])); // Execute GraphQL mutation to delete a todo by primary key // Hasura permissions ensure users can only delete their own todos const response = await nhost.graphql.request({ query: ` mutation DeleteTodo($id: uuid!) { delete_todos_by_pk(id: $id) { id } } `, variables: { id, }, }); if (response.body.errors) { throw new Error( response.body.errors[0]?.message || "Failed to delete todo", ); } setTodos(todos.filter((todo) => todo.id !== id)); setError(null); } catch (err) { setError( err instanceof Error ? err.message : "Failed to delete todo", ); Alert.alert( "Error", err instanceof Error ? err.message : "Failed to delete todo", ); } finally { setUpdatingTodos((prev) => { const newSet = new Set(prev); newSet.delete(id); return newSet; }); } }, }, ]); }; const toggleComplete = async (todo: Todo) => { await updateTodo(todo.id, { completed: !todo.completed }); }; const saveEdit = async () => { if (!editingTodo) return; await updateTodo(editingTodo.id, { title: editingTodo.title, details: editingTodo.details, }); }; const toggleTodoExpansion = (todoId: string) => { const newExpanded = new Set(expandedTodos); if (newExpanded.has(todoId)) { newExpanded.delete(todoId); } else { newExpanded.add(todoId); } setExpandedTodos(newExpanded); }; // Fetch todos when user session is available // The session contains the JWT token needed for GraphQL authentication useEffect(() => { if (session) { fetchTodos(); } }, [session, fetchTodos]); if (!session) { return null; // Will redirect to sign in } const renderTodoItem = ({ item: todo }: { item: Todo }) => { const isUpdating = updatingTodos.has(todo.id); const isExpanded = expandedTodos.has(todo.id); return ( {editingTodo?.id === todo.id ? ( Title setEditingTodo({ ...editingTodo, title: text, }) } placeholder="Enter todo title" placeholderTextColor={colors.textPlaceholder} /> Details setEditingTodo({ ...editingTodo, details: text, }) } placeholder="Enter details (optional)" placeholderTextColor={colors.textPlaceholder} multiline numberOfLines={3} /> {isUpdating ? "Saving..." : "Save"} setEditingTodo(null)} > Cancel ) : ( toggleTodoExpansion(todo.id)} > {todo.title} toggleComplete(todo)} disabled={isUpdating} > {isUpdating ? "⌛" : todo.completed ? "↶" : "✓"} setEditingTodo(todo)} > ✏️ deleteTodo(todo.id)} disabled={isUpdating} > 🗑️ {isExpanded && ( {todo.details && ( {todo.details} )} Created: {new Date(todo.created_at).toLocaleString()} Updated: {new Date(todo.updated_at).toLocaleString()} {todo.completed && ( ✅ Completed )} )} )} ); }; const renderHeader = () => ( <> My Todos {!showAddForm && ( setShowAddForm(true)} > + )} {error && ( Error: {error} )} {showAddForm && ( Add New Todo Title * Details {addingTodo ? "Adding..." : "Add Todo"} { setShowAddForm(false); setNewTodoTitle(""); setNewTodoDetails(""); }} > Cancel )} ); const renderEmptyState = () => ( No todos yet Create your first todo to get started! ); if (loading) { return ( Loading todos... ); } return ( {renderHeader()} item.id} ListEmptyComponent={!showAddForm ? renderEmptyState : null} showsVerticalScrollIndicator={false} contentContainerStyle={commonStyles.listContainer} keyboardShouldPersistTaps="handled" /> ); }