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<Todo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newTodoTitle, setNewTodoTitle] = useState("");
const [newTodoDetails, setNewTodoDetails] = useState("");
const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [expandedTodos, setExpandedTodos] = useState<Set<string>>(new Set());
const [addingTodo, setAddingTodo] = useState(false);
const [updatingTodos, setUpdatingTodos] = useState<Set<string>>(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<GetTodos>({
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<InsertTodo>({
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<Pick<Todo, "title" | "details" | "completed">>,
) => {
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<UpdateTodo>({
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 (
<View
style={[
commonStyles.todoCard,
todo.completed && commonStyles.todoCompleted,
]}
>
{editingTodo?.id === todo.id ? (
<View style={commonStyles.todoEditForm}>
<Text style={commonStyles.inputLabel}>Title</Text>
<TextInput
style={commonStyles.input}
value={editingTodo.title}
onChangeText={(text) =>
setEditingTodo({
...editingTodo,
title: text,
})
}
placeholder="Enter todo title"
placeholderTextColor={colors.textPlaceholder}
/>
<Text style={commonStyles.inputLabel}>Details</Text>
<TextInput
style={[commonStyles.input, commonStyles.textArea]}
value={editingTodo.details || ""}
onChangeText={(text) =>
setEditingTodo({
...editingTodo,
details: text,
})
}
placeholder="Enter details (optional)"
placeholderTextColor={colors.textPlaceholder}
multiline
numberOfLines={3}
/>
<View style={commonStyles.buttonGroup}>
<TouchableOpacity
style={[commonStyles.button, commonStyles.primaryButton]}
onPress={saveEdit}
disabled={isUpdating}
>
<Text style={commonStyles.buttonText}>
{isUpdating ? "Saving..." : "Save"}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[commonStyles.button, commonStyles.secondaryButton]}
onPress={() => setEditingTodo(null)}
>
<Text
style={[
commonStyles.buttonText,
commonStyles.secondaryButtonText,
]}
>
Cancel
</Text>
</TouchableOpacity>
</View>
</View>
) : (
<View>
<View style={commonStyles.todoHeader}>
<TouchableOpacity
style={commonStyles.todoTitleContainer}
onPress={() => toggleTodoExpansion(todo.id)}
>
<Text
style={[
commonStyles.todoTitle,
todo.completed && commonStyles.todoTitleCompleted,
]}
>
{todo.title}
</Text>
</TouchableOpacity>
<View style={commonStyles.todoActions}>
<TouchableOpacity
style={[
commonStyles.actionButton,
commonStyles.completeButton,
]}
onPress={() => toggleComplete(todo)}
disabled={isUpdating}
>
<Text style={commonStyles.actionButtonText}>
{isUpdating ? "⌛" : todo.completed ? "↶" : "✓"}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[commonStyles.actionButton, commonStyles.editButton]}
onPress={() => setEditingTodo(todo)}
>
<Text style={commonStyles.actionButtonText}>✏️</Text>
</TouchableOpacity>
<TouchableOpacity
style={[commonStyles.actionButton, commonStyles.deleteButton]}
onPress={() => deleteTodo(todo.id)}
disabled={isUpdating}
>
<Text style={commonStyles.actionButtonText}>🗑️</Text>
</TouchableOpacity>
</View>
</View>
{isExpanded && (
<View style={commonStyles.todoDetails}>
{todo.details && (
<Text
style={[
commonStyles.todoDescription,
todo.completed && commonStyles.todoDescriptionCompleted,
]}
>
{todo.details}
</Text>
)}
<View style={commonStyles.todoMeta}>
<Text style={commonStyles.metaText}>
Created: {new Date(todo.created_at).toLocaleString()}
</Text>
<Text style={commonStyles.metaText}>
Updated: {new Date(todo.updated_at).toLocaleString()}
</Text>
{todo.completed && (
<View style={commonStyles.completionBadge}>
<Text style={commonStyles.completionText}>
✅ Completed
</Text>
</View>
)}
</View>
</View>
)}
</View>
)}
</View>
);
};
const renderHeader = () => (
<>
<View style={commonStyles.pageHeader}>
<Text style={commonStyles.pageTitle}>My Todos</Text>
{!showAddForm && (
<TouchableOpacity
style={commonStyles.addButton}
onPress={() => setShowAddForm(true)}
>
<Text style={commonStyles.addButtonText}>+</Text>
</TouchableOpacity>
)}
</View>
{error && (
<View style={[commonStyles.errorContainer, { marginHorizontal: 16 }]}>
<Text style={commonStyles.errorText}>Error: {error}</Text>
</View>
)}
{showAddForm && (
<View style={[commonStyles.card, { marginHorizontal: 16, width: undefined }]}>
<Text style={commonStyles.cardTitle}>Add New Todo</Text>
<View style={commonStyles.formFields}>
<View style={commonStyles.fieldGroup}>
<Text style={commonStyles.inputLabel}>Title *</Text>
<TextInput
style={commonStyles.input}
value={newTodoTitle}
onChangeText={setNewTodoTitle}
placeholder="What needs to be done?"
placeholderTextColor={colors.textPlaceholder}
/>
</View>
<View style={commonStyles.fieldGroup}>
<Text style={commonStyles.inputLabel}>Details</Text>
<TextInput
style={[commonStyles.input, commonStyles.textArea]}
value={newTodoDetails}
onChangeText={setNewTodoDetails}
placeholder="Add some details (optional)..."
placeholderTextColor={colors.textPlaceholder}
multiline
numberOfLines={3}
/>
</View>
<View style={commonStyles.buttonGroup}>
<TouchableOpacity
style={[commonStyles.button, commonStyles.primaryButton]}
onPress={addTodo}
disabled={addingTodo || !newTodoTitle.trim()}
>
<Text style={commonStyles.buttonText}>
{addingTodo ? "Adding..." : "Add Todo"}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[commonStyles.button, commonStyles.secondaryButton]}
onPress={() => {
setShowAddForm(false);
setNewTodoTitle("");
setNewTodoDetails("");
}}
>
<Text
style={[
commonStyles.buttonText,
commonStyles.secondaryButtonText,
]}
>
Cancel
</Text>
</TouchableOpacity>
</View>
</View>
</View>
)}
</>
);
const renderEmptyState = () => (
<View style={commonStyles.emptyState}>
<Text style={commonStyles.emptyStateTitle}>No todos yet</Text>
<Text style={commonStyles.emptyStateText}>
Create your first todo to get started!
</Text>
</View>
);
if (loading) {
return (
<ProtectedScreen>
<Stack.Screen options={{ title: "My Todos" }} />
<View style={commonStyles.loadingContainer}>
<ActivityIndicator size="large" color="#6366f1" />
<Text style={commonStyles.loadingText}>Loading todos...</Text>
</View>
</ProtectedScreen>
);
}
return (
<ProtectedScreen>
<Stack.Screen options={{ title: "My Todos" }} />
<View style={commonStyles.container}>
{renderHeader()}
<FlatList
data={showAddForm ? [] : todos}
renderItem={renderTodoItem}
keyExtractor={(item) => item.id}
ListEmptyComponent={!showAddForm ? renderEmptyState : null}
showsVerticalScrollIndicator={false}
contentContainerStyle={commonStyles.listContainer}
keyboardShouldPersistTaps="handled"
/>
</View>
</ProtectedScreen>
);
}