← Volver al listado de tecnologías

Capítulo 13: CRUD de Tareas por Usuario

Por: Artiko
react-nativecrudformsvalidationuxswipe-actionspull-to-refresh

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

📚 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

📚 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.