← Volver al listado de tecnologías
Capítulo 13: CRUD de Tareas por Usuario
Capítulo 13: CRUD de Tareas por Usuario
En este capítulo implementaremos la interfaz completa de CRUD para tareas, incluyendo formularios con validación robusta, swipe actions, pull to refresh y una UX optimizada para usuarios autenticados.
🎯 Objetivos del Capítulo
- Crear pantallas completas de CRUD de tareas
- Implementar formularios con validación robusta
- Agregar swipe actions para acciones rápidas
- Configurar pull to refresh y paginación
- Optimizar la UX con loading states y feedback
- Testing completo de componentes y flujos
📚 Contenido
1. Pantalla Principal de Tareas
// src/screens/TodosScreen.tsx
import React, { useState, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
StatusBar,
SafeAreaView,
} from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { TodoList } from '../components/TodoList';
import { TodoFilters } from '../components/TodoFilters';
import { CreateTodoFAB } from '../components/CreateTodoFAB';
import { TodoStats } from '../components/TodoStats';
import { useTodosData } from '../hooks/useTodosData';
import { useCategories } from '../hooks/queries/useCategories';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/types';
type Props = NativeStackScreenProps<RootStackParamList, 'Todos'>;
export const TodosScreen: React.FC<Props> = ({ navigation }) => {
const [filters, setFilters] = useState({
completed: undefined as boolean | undefined,
categoryId: undefined as string | undefined,
priority: undefined as 'low' | 'medium' | 'high' | undefined,
});
const [searchQuery, setSearchQuery] = useState('');
const { todos, stats, isLoading, error, refetch } = useTodosData({
...filters,
search: searchQuery,
});
const { data: categories } = useCategories();
// Refetch cuando la pantalla obtiene foco
useFocusEffect(
useCallback(() => {
refetch();
}, [refetch])
);
const handleCreateTodo = () => {
navigation.navigate('CreateTodo');
};
const handleEditTodo = (todoId: string) => {
navigation.navigate('EditTodo', { todoId });
};
const handleViewTodo = (todoId: string) => {
navigation.navigate('TodoDetail', { todoId });
};
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="#fff" />
<View style={styles.header}>
<Text style={styles.title}>Mis Tareas</Text>
<TouchableOpacity
style={styles.settingsButton}
onPress={() => navigation.navigate('Settings')}
>
<Text style={styles.settingsIcon}>⚙️</Text>
</TouchableOpacity>
</View>
<TodoStats stats={stats} />
<TodoFilters
filters={filters}
onFiltersChange={setFilters}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
categories={categories || []}
/>
<TodoList
todos={todos}
isLoading={isLoading}
error={error}
onRefresh={refetch}
onEdit={handleEditTodo}
onView={handleViewTodo}
emptyMessage={
Object.values(filters).some(Boolean) || searchQuery
? 'No se encontraron tareas con los filtros aplicados'
: 'No tienes tareas aún. ¡Crea tu primera tarea!'
}
/>
<CreateTodoFAB onPress={handleCreateTodo} />
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8f9fa',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 16,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#e9ecef',
},
title: {
fontSize: 24,
fontWeight: 'bold',
color: '#212529',
},
settingsButton: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f8f9fa',
},
settingsIcon: {
fontSize: 20,
},
});
2. Componente de Estadísticas
// src/components/TodoStats.tsx
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
interface TodoStatsProps {
stats: {
total: number;
completed: number;
pending: number;
completionRate: number;
};
}
export const TodoStats: React.FC<TodoStatsProps> = ({ stats }) => {
return (
<View style={styles.container}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{stats.total}</Text>
<Text style={styles.statLabel}>Total</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: '#28a745' }]}>
{stats.completed}
</Text>
<Text style={styles.statLabel}>Completadas</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: '#dc3545' }]}>
{stats.pending}
</Text>
<Text style={styles.statLabel}>Pendientes</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: '#007bff' }]}>
{stats.completionRate.toFixed(0)}%
</Text>
<Text style={styles.statLabel}>Progreso</Text>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
backgroundColor: '#fff',
paddingVertical: 16,
paddingHorizontal: 20,
borderBottomWidth: 1,
borderBottomColor: '#e9ecef',
},
statItem: {
flex: 1,
alignItems: 'center',
},
statValue: {
fontSize: 24,
fontWeight: 'bold',
color: '#212529',
marginBottom: 4,
},
statLabel: {
fontSize: 12,
color: '#6c757d',
textAlign: 'center',
},
});
3. Componente de Filtros
// src/components/TodoFilters.tsx
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
Modal,
} from 'react-native';
import { Category } from '../types/api';
interface Filters {
completed?: boolean;
categoryId?: string;
priority?: 'low' | 'medium' | 'high';
}
interface TodoFiltersProps {
filters: Filters;
onFiltersChange: (filters: Filters) => void;
searchQuery: string;
onSearchChange: (query: string) => void;
categories: Category[];
}
export const TodoFilters: React.FC<TodoFiltersProps> = ({
filters,
onFiltersChange,
searchQuery,
onSearchChange,
categories,
}) => {
const [showFilterModal, setShowFilterModal] = useState(false);
const hasActiveFilters = Object.values(filters).some(Boolean);
const clearFilters = () => {
onFiltersChange({});
onSearchChange('');
};
const updateFilter = (key: keyof Filters, value: any) => {
onFiltersChange({
...filters,
[key]: value === filters[key] ? undefined : value,
});
};
return (
<View style={styles.container}>
<View style={styles.searchContainer}>
<TextInput
style={styles.searchInput}
placeholder="Buscar tareas..."
value={searchQuery}
onChangeText={onSearchChange}
/>
<TouchableOpacity
style={[styles.filterButton, hasActiveFilters && styles.filterButtonActive]}
onPress={() => setShowFilterModal(true)}
>
<Text style={styles.filterButtonText}>🔍</Text>
</TouchableOpacity>
</View>
{hasActiveFilters && (
<View style={styles.activeFilters}>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={styles.filterTags}>
{filters.completed !== undefined && (
<TouchableOpacity
style={styles.filterTag}
onPress={() => updateFilter('completed', filters.completed)}
>
<Text style={styles.filterTagText}>
{filters.completed ? 'Completadas' : 'Pendientes'}
</Text>
<Text style={styles.filterTagClose}>×</Text>
</TouchableOpacity>
)}
{filters.categoryId && (
<TouchableOpacity
style={styles.filterTag}
onPress={() => updateFilter('categoryId', filters.categoryId)}
>
<Text style={styles.filterTagText}>
{categories.find(c => c.id === filters.categoryId)?.name || 'Categoría'}
</Text>
<Text style={styles.filterTagClose}>×</Text>
</TouchableOpacity>
)}
{filters.priority && (
<TouchableOpacity
style={styles.filterTag}
onPress={() => updateFilter('priority', filters.priority)}
>
<Text style={styles.filterTagText}>
Prioridad {filters.priority}
</Text>
<Text style={styles.filterTagClose}>×</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.clearFiltersButton}
onPress={clearFilters}
>
<Text style={styles.clearFiltersText}>Limpiar filtros</Text>
</TouchableOpacity>
</View>
</ScrollView>
</View>
)}
<Modal
visible={showFilterModal}
animationType="slide"
presentationStyle="pageSheet"
>
<View style={styles.modalContainer}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Filtros</Text>
<TouchableOpacity onPress={() => setShowFilterModal(false)}>
<Text style={styles.modalCloseButton}>Cerrar</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.modalContent}>
<View style={styles.filterSection}>
<Text style={styles.filterSectionTitle}>Estado</Text>
<View style={styles.filterOptions}>
<TouchableOpacity
style={[
styles.filterOption,
filters.completed === undefined && styles.filterOptionActive,
]}
onPress={() => updateFilter('completed', undefined)}
>
<Text style={styles.filterOptionText}>Todas</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.filterOption,
filters.completed === false && styles.filterOptionActive,
]}
onPress={() => updateFilter('completed', false)}
>
<Text style={styles.filterOptionText}>Pendientes</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.filterOption,
filters.completed === true && styles.filterOptionActive,
]}
onPress={() => updateFilter('completed', true)}
>
<Text style={styles.filterOptionText}>Completadas</Text>
</TouchableOpacity>
</View>
</View>
<View style={styles.filterSection}>
<Text style={styles.filterSectionTitle}>Prioridad</Text>
<View style={styles.filterOptions}>
<TouchableOpacity
style={[
styles.filterOption,
!filters.priority && styles.filterOptionActive,
]}
onPress={() => updateFilter('priority', undefined)}
>
<Text style={styles.filterOptionText}>Todas</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.filterOption,
filters.priority === 'high' && styles.filterOptionActive,
]}
onPress={() => updateFilter('priority', 'high')}
>
<Text style={styles.filterOptionText}>Alta</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.filterOption,
filters.priority === 'medium' && styles.filterOptionActive,
]}
onPress={() => updateFilter('priority', 'medium')}
>
<Text style={styles.filterOptionText}>Media</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.filterOption,
filters.priority === 'low' && styles.filterOptionActive,
]}
onPress={() => updateFilter('priority', 'low')}
>
<Text style={styles.filterOptionText}>Baja</Text>
</TouchableOpacity>
</View>
</View>
<View style={styles.filterSection}>
<Text style={styles.filterSectionTitle}>Categoría</Text>
<View style={styles.filterOptions}>
<TouchableOpacity
style={[
styles.filterOption,
!filters.categoryId && styles.filterOptionActive,
]}
onPress={() => updateFilter('categoryId', undefined)}
>
<Text style={styles.filterOptionText}>Todas</Text>
</TouchableOpacity>
{categories.map((category) => (
<TouchableOpacity
key={category.id}
style={[
styles.filterOption,
filters.categoryId === category.id && styles.filterOptionActive,
]}
onPress={() => updateFilter('categoryId', category.id)}
>
<View style={styles.categoryOption}>
<View
style={[
styles.categoryColor,
{ backgroundColor: category.color },
]}
/>
<Text style={styles.filterOptionText}>{category.name}</Text>
</View>
</TouchableOpacity>
))}
</View>
</View>
</ScrollView>
</View>
</Modal>
</View>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#e9ecef',
},
searchContainer: {
flexDirection: 'row',
paddingHorizontal: 20,
paddingVertical: 12,
alignItems: 'center',
},
searchInput: {
flex: 1,
height: 40,
borderWidth: 1,
borderColor: '#ced4da',
borderRadius: 8,
paddingHorizontal: 12,
marginRight: 12,
backgroundColor: '#f8f9fa',
},
filterButton: {
width: 40,
height: 40,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f8f9fa',
borderWidth: 1,
borderColor: '#ced4da',
},
filterButtonActive: {
backgroundColor: '#007bff',
borderColor: '#007bff',
},
filterButtonText: {
fontSize: 16,
},
activeFilters: {
paddingHorizontal: 20,
paddingBottom: 12,
},
filterTags: {
flexDirection: 'row',
alignItems: 'center',
},
filterTag: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#007bff',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
marginRight: 8,
},
filterTagText: {
color: '#fff',
fontSize: 12,
marginRight: 4,
},
filterTagClose: {
color: '#fff',
fontSize: 16,
fontWeight: 'bold',
},
clearFiltersButton: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
backgroundColor: '#dc3545',
},
clearFiltersText: {
color: '#fff',
fontSize: 12,
},
modalContainer: {
flex: 1,
backgroundColor: '#fff',
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: '#e9ecef',
},
modalTitle: {
fontSize: 18,
fontWeight: 'bold',
},
modalCloseButton: {
color: '#007bff',
fontSize: 16,
},
modalContent: {
flex: 1,
paddingHorizontal: 20,
},
filterSection: {
marginVertical: 20,
},
filterSectionTitle: {
fontSize: 16,
fontWeight: 'bold',
marginBottom: 12,
color: '#212529',
},
filterOptions: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
filterOption: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: '#f8f9fa',
borderWidth: 1,
borderColor: '#ced4da',
},
filterOptionActive: {
backgroundColor: '#007bff',
borderColor: '#007bff',
},
filterOptionText: {
fontSize: 14,
color: '#212529',
},
categoryOption: {
flexDirection: 'row',
alignItems: 'center',
},
categoryColor: {
width: 12,
height: 12,
borderRadius: 6,
marginRight: 8,
},
});
4. Formulario de Crear/Editar Tarea
// src/components/TodoForm.tsx
import React, { useState, useEffect } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
Alert,
Platform,
} from 'react-native';
import DateTimePicker from '@react-native-community/datetimepicker';
import { useCategories } from '../hooks/queries/useCategories';
import { CreateTodoRequest, UpdateTodoRequest, Todo } from '../types/api';
interface TodoFormProps {
initialData?: Todo;
onSubmit: (data: CreateTodoRequest | UpdateTodoRequest) => void;
onCancel: () => void;
isLoading?: boolean;
}
export const TodoForm: React.FC<TodoFormProps> = ({
initialData,
onSubmit,
onCancel,
isLoading = false,
}) => {
const [formData, setFormData] = useState({
title: initialData?.title || '',
description: initialData?.description || '',
priority: initialData?.priority || 'medium' as 'low' | 'medium' | 'high',
categoryId: initialData?.categoryId || '',
dueDate: initialData?.dueDate ? new Date(initialData.dueDate) : undefined,
});
const [showDatePicker, setShowDatePicker] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const { data: categories } = useCategories();
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.title.trim()) {
newErrors.title = 'El título es obligatorio';
} else if (formData.title.length < 3) {
newErrors.title = 'El título debe tener al menos 3 caracteres';
} else if (formData.title.length > 100) {
newErrors.title = 'El título no puede tener más de 100 caracteres';
}
if (formData.description && formData.description.length > 500) {
newErrors.description = 'La descripción no puede tener más de 500 caracteres';
}
if (formData.dueDate && formData.dueDate < new Date()) {
newErrors.dueDate = 'La fecha de vencimiento debe ser futura';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = () => {
if (!validateForm()) return;
const submitData = {
title: formData.title.trim(),
description: formData.description.trim() || undefined,
priority: formData.priority,
categoryId: formData.categoryId || undefined,
dueDate: formData.dueDate?.toISOString(),
};
onSubmit(submitData);
};
const handleDateChange = (event: any, selectedDate?: Date) => {
setShowDatePicker(false);
if (selectedDate) {
setFormData(prev => ({ ...prev, dueDate: selectedDate }));
}
};
const clearDueDate = () => {
setFormData(prev => ({ ...prev, dueDate: undefined }));
};
return (
<ScrollView style={styles.container} keyboardShouldPersistTaps="handled">
<View style={styles.form}>
<View style={styles.field}>
<Text style={styles.label}>Título *</Text>
<TextInput
style={[styles.input, errors.title && styles.inputError]}
value={formData.title}
onChangeText={(text) => {
setFormData(prev => ({ ...prev, title: text }));
if (errors.title) {
setErrors(prev => ({ ...prev, title: '' }));
}
}}
placeholder="Ingresa el título de la tarea"
maxLength={100}
/>
{errors.title && <Text style={styles.errorText}>{errors.title}</Text>}
</View>
<View style={styles.field}>
<Text style={styles.label}>Descripción</Text>
<TextInput
style={[styles.textArea, errors.description && styles.inputError]}
value={formData.description}
onChangeText={(text) => {
setFormData(prev => ({ ...prev, description: text }));
if (errors.description) {
setErrors(prev => ({ ...prev, description: '' }));
}
}}
placeholder="Describe la tarea (opcional)"
multiline
numberOfLines={4}
maxLength={500}
/>
{errors.description && <Text style={styles.errorText}>{errors.description}</Text>}
<Text style={styles.characterCount}>
{formData.description.length}/500
</Text>
</View>
<View style={styles.field}>
<Text style={styles.label}>Prioridad</Text>
<View style={styles.priorityContainer}>
{(['low', 'medium', 'high'] as const).map((priority) => (
<TouchableOpacity
key={priority}
style={[
styles.priorityButton,
formData.priority === priority && styles.priorityButtonActive,
styles[`priority${priority.charAt(0).toUpperCase() + priority.slice(1)}`],
]}
onPress={() => setFormData(prev => ({ ...prev, priority }))}
>
<Text style={[
styles.priorityButtonText,
formData.priority === priority && styles.priorityButtonTextActive,
]}>
{priority === 'low' ? 'Baja' : priority === 'medium' ? 'Media' : 'Alta'}
</Text>
</TouchableOpacity>
))}
</View>
</View>
<View style={styles.field}>
<Text style={styles.label}>Categoría</Text>
<View style={styles.categoryContainer}>
<TouchableOpacity
style={[
styles.categoryButton,
!formData.categoryId && styles.categoryButtonActive,
]}
onPress={() => setFormData(prev => ({ ...prev, categoryId: '' }))}
>
<Text style={styles.categoryButtonText}>Sin categoría</Text>
</TouchableOpacity>
{categories?.map((category) => (
<TouchableOpacity
key={category.id}
style={[
styles.categoryButton,
formData.categoryId === category.id && styles.categoryButtonActive,
]}
onPress={() => setFormData(prev => ({ ...prev, categoryId: category.id }))}
>
<View style={styles.categoryButtonContent}>
<View
style={[
styles.categoryColor,
{ backgroundColor: category.color },
]}
/>
<Text style={styles.categoryButtonText}>{category.name}</Text>
</View>
</TouchableOpacity>
))}
</View>
</View>
<View style={styles.field}>
<Text style={styles.label}>Fecha de vencimiento</Text>
<View style={styles.dateContainer}>
<TouchableOpacity
style={styles.dateButton}
onPress={() => setShowDatePicker(true)}
>
<Text style={styles.dateButtonText}>
{formData.dueDate
? formData.dueDate.toLocaleDateString('es-ES')
: 'Seleccionar fecha'
}
</Text>
</TouchableOpacity>
{formData.dueDate && (
<TouchableOpacity
style={styles.clearDateButton}
onPress={clearDueDate}
>
<Text style={styles.clearDateButtonText}>×</Text>
</TouchableOpacity>
)}
</View>
{errors.dueDate && <Text style={styles.errorText}>{errors.dueDate}</Text>}
</View>
<View style={styles.buttonContainer}>
<TouchableOpacity
style={styles.cancelButton}
onPress={onCancel}
disabled={isLoading}
>
<Text style={styles.cancelButtonText}>Cancelar</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.submitButton, isLoading && styles.submitButtonDisabled]}
onPress={handleSubmit}
disabled={isLoading}
>
<Text style={styles.submitButtonText}>
{isLoading ? 'Guardando...' : initialData ? 'Actualizar' : 'Crear'}
</Text>
</TouchableOpacity>
</View>
</View>
{showDatePicker && (
<DateTimePicker
value={formData.dueDate || new Date()}
mode="date"
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
onChange={handleDateChange}
minimumDate={new Date()}
/>
)}
</ScrollView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
form: {
padding: 20,
},
field: {
marginBottom: 20,
},
label: {
fontSize: 16,
fontWeight: '600',
color: '#212529',
marginBottom: 8,
},
input: {
borderWidth: 1,
borderColor: '#ced4da',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 16,
backgroundColor: '#fff',
},
textArea: {
borderWidth: 1,
borderColor: '#ced4da',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 16,
backgroundColor: '#fff',
height: 100,
textAlignVertical: 'top',
},
inputError: {
borderColor: '#dc3545',
},
errorText: {
color: '#dc3545',
fontSize: 12,
marginTop: 4,
},
characterCount: {
fontSize: 12,
color: '#6c757d',
textAlign: 'right',
marginTop: 4,
},
priorityContainer: {
flexDirection: 'row',
gap: 8,
},
priorityButton: {
flex: 1,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
borderWidth: 1,
borderColor: '#ced4da',
alignItems: 'center',
},
priorityButtonActive: {
borderWidth: 2,
},
priorityLow: {
backgroundColor: '#d4edda',
},
priorityMedium: {
backgroundColor: '#fff3cd',
},
priorityHigh: {
backgroundColor: '#f8d7da',
},
priorityButtonText: {
fontSize: 14,
fontWeight: '500',
color: '#212529',
},
priorityButtonTextActive: {
fontWeight: 'bold',
},
categoryContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
categoryButton: {
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 16,
borderWidth: 1,
borderColor: '#ced4da',
backgroundColor: '#f8f9fa',
},
categoryButtonActive: {
backgroundColor: '#007bff',
borderColor: '#007bff',
},
categoryButtonContent: {
flexDirection: 'row',
alignItems: 'center',
},
categoryColor: {
width: 12,
height: 12,
borderRadius: 6,
marginRight: 6,
},
categoryButtonText: {
fontSize: 12,
color: '#212529',
},
dateContainer: {
flexDirection: 'row',
alignItems: 'center',
},
dateButton: {
flex: 1,
borderWidth: 1,
borderColor: '#ced4da',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
backgroundColor: '#fff',
},
dateButtonText: {
fontSize: 16,
color: '#212529',
},
clearDateButton: {
marginLeft: 8,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#dc3545',
justifyContent: 'center',
alignItems: 'center',
},
clearDateButtonText: {
color: '#fff',
fontSize: 18,
fontWeight: 'bold',
},
buttonContainer: {
flexDirection: 'row',
gap: 12,
marginTop: 20,
},
cancelButton: {
flex: 1,
paddingVertical: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: '#6c757d',
alignItems: 'center',
},
cancelButtonText: {
fontSize: 16,
color: '#6c757d',
fontWeight: '500',
},
submitButton: {
flex: 1,
paddingVertical: 12,
borderRadius: 8,
backgroundColor: '#007bff',
alignItems: 'center',
},
submitButtonDisabled: {
backgroundColor: '#6c757d',
},
submitButtonText: {
fontSize: 16,
color: '#fff',
fontWeight: '600',
},
});
5. Pantalla de Crear Tarea
// src/screens/CreateTodoScreen.tsx
import React from 'react';
import { View, StyleSheet, SafeAreaView, Alert } from 'react-native';
import { TodoForm } from '../components/TodoForm';
import { useCreateTodo } from '../hooks/mutations/useTodoMutations';
import { CreateTodoRequest } from '../types/api';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/types';
type Props = NativeStackScreenProps<RootStackParamList, 'CreateTodo'>;
export const CreateTodoScreen: React.FC<Props> = ({ navigation }) => {
const createTodo = useCreateTodo();
const handleSubmit = (data: CreateTodoRequest) => {
createTodo.mutate(data, {
onSuccess: () => {
navigation.goBack();
},
onError: (error) => {
Alert.alert('Error', 'No se pudo crear la tarea. Intenta nuevamente.');
},
});
};
const handleCancel = () => {
navigation.goBack();
};
return (
<SafeAreaView style={styles.container}>
<TodoForm
onSubmit={handleSubmit}
onCancel={handleCancel}
isLoading={createTodo.isPending}
/>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
});
6. FAB para Crear Tarea
// src/components/CreateTodoFAB.tsx
import React from 'react';
import { TouchableOpacity, Text, StyleSheet } from 'react-native';
interface CreateTodoFABProps {
onPress: () => void;
}
export const CreateTodoFAB: React.FC<CreateTodoFABProps> = ({ onPress }) => {
return (
<TouchableOpacity style={styles.fab} onPress={onPress}>
<Text style={styles.fabText}>+</Text>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
fab: {
position: 'absolute',
bottom: 20,
right: 20,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#007bff',
justifyContent: 'center',
alignItems: 'center',
elevation: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
},
fabText: {
fontSize: 24,
color: '#fff',
fontWeight: 'bold',
},
});
🔗 Navegación
- ← Capítulo 12: Queries y Mutations Autenticadas
- → Capítulo 14: Categorías y Filtros Personalizados
- 🏠 Volver al índice
📚 Recursos Adicionales
¡Excelente! Has implementado un sistema completo de CRUD de tareas con formularios validados, filtros avanzados y una UX optimizada. En el próximo capítulo expandiremos las funcionalidades con categorías y filtros personalizados.