Débutant 22 min de lecture · 4 697 mots

React Native pour débutants : Premier projet mobile complet 2025

React Native pour débutants : Premier projet mobile complet 2025
Débutant · 2025.12.18

React Native permet de créer des applications mobiles natives pour iOS et Android en utilisant JavaScript et React. Dans ce guide complet, nous allons créer ensemble une application de gestion de tâches professionnelle, de l’installation à la publication.

1. Prérequis et installation de l’environnement

1.1 Configuration système requise

Pour développer sur iOS et Android :

  • macOS 12+ (pour iOS)
  • Windows 10+ ou Linux (pour Android uniquement)
  • Node.js 18+ et npm/yarn
  • Git
  • Outils de développement :

  • Visual Studio Code avec extensions
  • Xcode 14+ (macOS uniquement)
  • Android Studio
  • 1.2 Installation pas à pas

# 1. Vérifier Node.js (version 18+)
node --version
npm --version

# 2. Installer React Native CLI
npm install -g react-native-cli

# 3. Installer Expo CLI (optionnel, recommandé pour débuter)
npm install -g expo-cli

# 4. Vérifier l'installation
npx react-native --version

# 5. Installer Watchman (macOS, pour le hot reload)
brew install watchman

# 6. Installer CocoaPods (macOS, pour iOS)
sudo gem install cocoapods

1.3 Configuration Android Studio

# Télécharger Android Studio depuis developer.android.com

# Configurer les variables d'environnement (Linux/macOS)
# Ajouter à ~/.bashrc ou ~/.zshrc
export ANDROID_HOME=$HOME/Library/Android/sdk
export PATH=$PATH:$ANDROID_HOME/emulator
export PATH=$PATH:$ANDROID_HOME/platform-tools
export PATH=$PATH:$ANDROID_HOME/tools
export PATH=$PATH:$ANDROID_HOME/tools/bin

# Recharger la configuration
source ~/.zshrc

# Vérifier la configuration
echo $ANDROID_HOME

# Accepter les licences
yes | sdkmanager --licenses

1.4 Configuration Xcode (macOS)

# Installer Xcode depuis l'App Store

# Installer les Command Line Tools
xcode-select --install

# Accepter la licence
sudo xcodebuild -license accept

# Vérifier l'installation
xcodebuild -version

2. Création du projet : TodoApp

2.1 Initialisation avec TypeScript

# Créer le projet avec template TypeScript
npx react-native init TodoApp --template react-native-template-typescript

# Naviguer dans le projet
cd TodoApp

# Structure du projet
# TodoApp/
# ├── android/              # Projet Android natif
# ├── ios/                  # Projet iOS natif
# ├── src/                  # Code source (à créer)
# ├── App.tsx               # Composant racine
# ├── package.json          # Dépendances
# ├── tsconfig.json         # Configuration TypeScript
# └── metro.config.js       # Bundler Metro

# Créer la structure src/
mkdir -p src/{components,screens,navigation,utils,types,hooks,services}

2.2 Installation des dépendances

# Navigation
npm install @react-navigation/native @react-navigation/stack @react-navigation/bottom-tabs

# Dépendances navigation
npm install react-native-screens react-native-safe-area-context react-native-gesture-handler

# Stockage local
npm install @react-native-async-storage/async-storage

# Gestion d'état
npm install zustand

# Utilitaires
npm install date-fns react-native-uuid

# Types TypeScript
npm install -D @types/react @types/react-native

# iOS : Installation des pods
cd ios && pod install && cd ..

2.3 Configuration TypeScript stricte

// tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "lib": ["es2021"],
    "jsx": "react-native",
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictPropertyInitialization": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "forceConsistentCasingInFileNames": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"],
      "@screens/*": ["src/screens/*"],
      "@navigation/*": ["src/navigation/*"],
      "@utils/*": ["src/utils/*"],
      "@types/*": ["src/types/*"],
      "@hooks/*": ["src/hooks/*"],
      "@services/*": ["src/services/*"]
    }
  },
  "include": ["src/**/*", "App.tsx"],
  "exclude": ["node_modules", "android", "ios"]
}

3. Architecture et types TypeScript

3.1 Définition des types

// src/types/todo.ts
export interface Todo {
  id: string;
  title: string;
  description: string;
  completed: boolean;
  priority: 'low' | 'medium' | 'high';
  dueDate: string | null;
  createdAt: string;
  updatedAt: string;
  category: string;
}

export interface TodoCategory {
  id: string;
  name: string;
  color: string;
  icon: string;
}

export type TodoFilter = 'all' | 'active' | 'completed';
export type TodoSort = 'date' | 'priority' | 'alphabetical';

// src/types/navigation.ts
import { StackNavigationProp } from '@react-navigation/stack';
import { RouteProp } from '@react-navigation/native';
import { Todo } from './todo';

export type RootStackParamList = {
  TodoList: undefined;
  TodoDetail: { todoId: string };
  AddTodo: { category?: string };
  EditTodo: { todo: Todo };
  Settings: undefined;
};

export type TodoListNavigationProp = StackNavigationProp<
  RootStackParamList,
  'TodoList'
>;

export type TodoDetailNavigationProp = StackNavigationProp<
  RootStackParamList,
  'TodoDetail'
>;

export type TodoDetailRouteProp = RouteProp<RootStackParamList, 'TodoDetail'>;

export type AddTodoNavigationProp = StackNavigationProp<
  RootStackParamList,
  'AddTodo'
>;

export type EditTodoNavigationProp = StackNavigationProp<
  RootStackParamList,
  'EditTodo'
>;

export type EditTodoRouteProp = RouteProp<RootStackParamList, 'EditTodo'>;

3.2 Service de stockage avec AsyncStorage

// src/services/storage.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Todo, TodoCategory } from '@/types/todo';

const TODOS_KEY = '@todos';
const CATEGORIES_KEY = '@categories';

class StorageService {
  // Gestion des todos
  async getTodos(): Promise<Todo[]> {
    try {
      const todosJson = await AsyncStorage.getItem(TODOS_KEY);
      return todosJson ? JSON.parse(todosJson) : [];
    } catch (error) {
      console.error('Erreur lecture todos:', error);
      return [];
    }
  }

  async saveTodos(todos: Todo[]): Promise<void> {
    try {
      await AsyncStorage.setItem(TODOS_KEY, JSON.stringify(todos));
    } catch (error) {
      console.error('Erreur sauvegarde todos:', error);
      throw new Error('Impossible de sauvegarder les tâches');
    }
  }

  async getTodoById(id: string): Promise<Todo | null> {
    try {
      const todos = await this.getTodos();
      return todos.find(todo => todo.id === id) || null;
    } catch (error) {
      console.error('Erreur récupération todo:', error);
      return null;
    }
  }

  async addTodo(todo: Todo): Promise<Todo> {
    try {
      const todos = await this.getTodos();
      const newTodos = [...todos, todo];
      await this.saveTodos(newTodos);
      return todo;
    } catch (error) {
      console.error('Erreur ajout todo:', error);
      throw new Error('Impossible d'ajouter la tâche');
    }
  }

  async updateTodo(id: string, updates: Partial<Todo>): Promise<Todo> {
    try {
      const todos = await this.getTodos();
      const index = todos.findIndex(todo => todo.id === id);

      if (index === -1) {
        throw new Error('Tâche introuvable');
      }

      const updatedTodo = {
        ...todos[index],
        ...updates,
        updatedAt: new Date().toISOString(),
      };

      todos[index] = updatedTodo;
      await this.saveTodos(todos);
      return updatedTodo;
    } catch (error) {
      console.error('Erreur mise à jour todo:', error);
      throw new Error('Impossible de mettre à jour la tâche');
    }
  }

  async deleteTodo(id: string): Promise<void> {
    try {
      const todos = await this.getTodos();
      const filteredTodos = todos.filter(todo => todo.id !== id);
      await this.saveTodos(filteredTodos);
    } catch (error) {
      console.error('Erreur suppression todo:', error);
      throw new Error('Impossible de supprimer la tâche');
    }
  }

  async toggleTodoComplete(id: string): Promise<Todo> {
    try {
      const todos = await this.getTodos();
      const todo = todos.find(t => t.id === id);

      if (!todo) {
        throw new Error('Tâche introuvable');
      }

      return await this.updateTodo(id, {
        completed: !todo.completed,
      });
    } catch (error) {
      console.error('Erreur toggle todo:', error);
      throw new Error('Impossible de modifier le statut');
    }
  }

  // Gestion des catégories
  async getCategories(): Promise<TodoCategory[]> {
    try {
      const categoriesJson = await AsyncStorage.getItem(CATEGORIES_KEY);

      if (!categoriesJson) {
        const defaultCategories = this.getDefaultCategories();
        await this.saveCategories(defaultCategories);
        return defaultCategories;
      }

      return JSON.parse(categoriesJson);
    } catch (error) {
      console.error('Erreur lecture catégories:', error);
      return this.getDefaultCategories();
    }
  }

  async saveCategories(categories: TodoCategory[]): Promise<void> {
    try {
      await AsyncStorage.setItem(CATEGORIES_KEY, JSON.stringify(categories));
    } catch (error) {
      console.error('Erreur sauvegarde catégories:', error);
      throw new Error('Impossible de sauvegarder les catégories');
    }
  }

  private getDefaultCategories(): TodoCategory[] {
    return [
      { id: '1', name: 'Personnel', color: '#3B82F6', icon: 'person' },
      { id: '2', name: 'Travail', color: '#10B981', icon: 'briefcase' },
      { id: '3', name: 'Shopping', color: '#F59E0B', icon: 'cart' },
      { id: '4', name: 'Santé', color: '#EF4444', icon: 'heart' },
    ];
  }

  // Utilitaires
  async clearAllData(): Promise<void> {
    try {
      await AsyncStorage.multiRemove([TODOS_KEY, CATEGORIES_KEY]);
    } catch (error) {
      console.error('Erreur suppression données:', error);
      throw new Error('Impossible de supprimer les données');
    }
  }
}

export default new StorageService();

3.3 Gestion d’état avec Zustand

// src/store/todoStore.ts
import { create } from 'zustand';
import { Todo, TodoFilter, TodoSort } from '@/types/todo';
import storageService from '@/services/storage';
import uuid from 'react-native-uuid';

interface TodoState {
  todos: Todo[];
  loading: boolean;
  error: string | null;
  filter: TodoFilter;
  sortBy: TodoSort;

  // Actions
  loadTodos: () => Promise<void>;
  addTodo: (
    title: string,
    description: string,
    priority: Todo['priority'],
    dueDate: string | null,
    category: string
  ) => Promise<void>;
  updateTodo: (id: string, updates: Partial<Todo>) => Promise<void>;
  deleteTodo: (id: string) => Promise<void>;
  toggleComplete: (id: string) => Promise<void>;
  setFilter: (filter: TodoFilter) => void;
  setSortBy: (sort: TodoSort) => void;
  clearError: () => void;

  // Getters
  getFilteredTodos: () => Todo[];
  getTodosByCategory: (category: string) => Todo[];
  getStats: () => {
    total: number;
    completed: number;
    active: number;
    overdue: number;
  };
}

export const useTodoStore = create<TodoState>((set, get) => ({
  todos: [],
  loading: false,
  error: null,
  filter: 'all',
  sortBy: 'date',

  loadTodos: async () => {
    set({ loading: true, error: null });

    try {
      const todos = await storageService.getTodos();
      set({ todos, loading: false });
    } catch (error) {
      set({
        error: error instanceof Error ? error.message : 'Erreur de chargement',
        loading: false,
      });
    }
  },

  addTodo: async (title, description, priority, dueDate, category) => {
    set({ loading: true, error: null });

    try {
      const newTodo: Todo = {
        id: uuid.v4() as string,
        title,
        description,
        completed: false,
        priority,
        dueDate,
        category,
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString(),
      };

      await storageService.addTodo(newTodo);
      const todos = await storageService.getTodos();
      set({ todos, loading: false });
    } catch (error) {
      set({
        error: error instanceof Error ? error.message : 'Erreur d'ajout',
        loading: false,
      });
    }
  },

  updateTodo: async (id, updates) => {
    set({ loading: true, error: null });

    try {
      await storageService.updateTodo(id, updates);
      const todos = await storageService.getTodos();
      set({ todos, loading: false });
    } catch (error) {
      set({
        error: error instanceof Error ? error.message : 'Erreur de mise à jour',
        loading: false,
      });
    }
  },

  deleteTodo: async (id) => {
    set({ loading: true, error: null });

    try {
      await storageService.deleteTodo(id);
      const todos = await storageService.getTodos();
      set({ todos, loading: false });
    } catch (error) {
      set({
        error: error instanceof Error ? error.message : 'Erreur de suppression',
        loading: false,
      });
    }
  },

  toggleComplete: async (id) => {
    try {
      await storageService.toggleTodoComplete(id);
      const todos = await storageService.getTodos();
      set({ todos });
    } catch (error) {
      set({
        error: error instanceof Error ? error.message : 'Erreur de modification',
      });
    }
  },

  setFilter: (filter) => set({ filter }),

  setSortBy: (sortBy) => set({ sortBy }),

  clearError: () => set({ error: null }),

  getFilteredTodos: () => {
    const { todos, filter, sortBy } = get();
    let filtered = todos;

    // Filtrage
    if (filter === 'active') {
      filtered = todos.filter(todo => !todo.completed);
    } else if (filter === 'completed') {
      filtered = todos.filter(todo => todo.completed);
    }

    // Tri
    const sorted = [...filtered].sort((a, b) => {
      switch (sortBy) {
        case 'date':
          return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();

        case 'priority':
          const priorityOrder = { high: 0, medium: 1, low: 2 };
          return priorityOrder[a.priority] - priorityOrder[b.priority];

        case 'alphabetical':
          return a.title.localeCompare(b.title);

        default:
          return 0;
      }
    });

    return sorted;
  },

  getTodosByCategory: (category) => {
    const { todos } = get();
    return todos.filter(todo => todo.category === category);
  },

  getStats: () => {
    const { todos } = get();
    const now = new Date();

    return {
      total: todos.length,
      completed: todos.filter(t => t.completed).length,
      active: todos.filter(t => !t.completed).length,
      overdue: todos.filter(t => {
        if (!t.dueDate || t.completed) return false;
        return new Date(t.dueDate) < now;
      }).length,
    };
  },
}));

4. Composants UI réutilisables

4.1 Composant TodoCard

// src/components/TodoCard.tsx
import React from 'react';
import {
  View,
  Text,
  StyleSheet,
  TouchableOpacity,
  Animated,
} from 'react-native';
import { Todo } from '@/types/todo';
import { format, isPast } from 'date-fns';
import { fr } from 'date-fns/locale';

interface TodoCardProps {
  todo: Todo;
  onPress: () => void;
  onToggleComplete: () => void;
  onDelete: () => void;
}

export const TodoCard: React.FC<TodoCardProps> = ({
  todo,
  onPress,
  onToggleComplete,
  onDelete,
}) => {
  const scaleAnim = React.useRef(new Animated.Value(1)).current;

  const handlePressIn = () => {
    Animated.spring(scaleAnim, {
      toValue: 0.97,
      useNativeDriver: true,
    }).start();
  };

  const handlePressOut = () => {
    Animated.spring(scaleAnim, {
      toValue: 1,
      useNativeDriver: true,
    }).start();
  };

  const getPriorityColor = () => {
    switch (todo.priority) {
      case 'high':
        return '#EF4444';
      case 'medium':
        return '#F59E0B';
      case 'low':
        return '#10B981';
      default:
        return '#6B7280';
    }
  };

  const isOverdue = todo.dueDate && isPast(new Date(todo.dueDate)) && !todo.completed;

  return (
    <Animated.View style={[styles.container, { transform: [{ scale: scaleAnim }] }]}>
      <TouchableOpacity
        onPress={onPress}
        onPressIn={handlePressIn}
        onPressOut={handlePressOut}
        activeOpacity={0.9}
        style={[styles.card, todo.completed && styles.completedCard]}
      >
        <View style={styles.header}>
          <TouchableOpacity
            onPress={onToggleComplete}
            style={styles.checkboxContainer}
            hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
          >
            <View
              style={[
                styles.checkbox,
                todo.completed && styles.checkboxChecked,
              ]}
            >
              {todo.completed && <Text style={styles.checkmark}>✓</Text>}
            </View>
          </TouchableOpacity>

          <View style={styles.content}>
            <Text
              style={[
                styles.title,
                todo.completed && styles.completedTitle,
              ]}
              numberOfLines={2}
            >
              {todo.title}
            </Text>

            {todo.description ? (
              <Text
                style={styles.description}
                numberOfLines={2}
              >
                {todo.description}
              </Text>
            ) : null}

            <View style={styles.metadata}>
              <View
                style={[
                  styles.priorityBadge,
                  { backgroundColor: getPriorityColor() },
                ]}
              >
                <Text style={styles.priorityText}>
                  {todo.priority === 'high' && 'Haute'}
                  {todo.priority === 'medium' && 'Moyenne'}
                  {todo.priority === 'low' && 'Basse'}
                </Text>
              </View>

              {todo.dueDate && (
                <Text
                  style={[
                    styles.dueDate,
                    isOverdue && styles.overdue,
                  ]}
                >
                  {format(new Date(todo.dueDate), 'dd MMM yyyy', { locale: fr })}
                </Text>
              )}
            </View>
          </View>

          <TouchableOpacity
            onPress={onDelete}
            style={styles.deleteButton}
            hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
          >
            <Text style={styles.deleteIcon}>×</Text>
          </TouchableOpacity>
        </View>
      </TouchableOpacity>
    </Animated.View>
  );
};

const styles = StyleSheet.create({
  container: {
    marginBottom: 12,
  },
  card: {
    backgroundColor: '#FFFFFF',
    borderRadius: 12,
    padding: 16,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 4,
    elevation: 3,
  },
  completedCard: {
    opacity: 0.7,
  },
  header: {
    flexDirection: 'row',
    alignItems: 'flex-start',
  },
  checkboxContainer: {
    marginRight: 12,
  },
  checkbox: {
    width: 24,
    height: 24,
    borderRadius: 12,
    borderWidth: 2,
    borderColor: '#D1D5DB',
    alignItems: 'center',
    justifyContent: 'center',
  },
  checkboxChecked: {
    backgroundColor: '#3B82F6',
    borderColor: '#3B82F6',
  },
  checkmark: {
    color: '#FFFFFF',
    fontSize: 16,
    fontWeight: 'bold',
  },
  content: {
    flex: 1,
  },
  title: {
    fontSize: 16,
    fontWeight: '600',
    color: '#1F2937',
    marginBottom: 4,
  },
  completedTitle: {
    textDecorationLine: 'line-through',
    color: '#9CA3AF',
  },
  description: {
    fontSize: 14,
    color: '#6B7280',
    marginBottom: 8,
  },
  metadata: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 8,
  },
  priorityBadge: {
    paddingHorizontal: 8,
    paddingVertical: 4,
    borderRadius: 4,
  },
  priorityText: {
    color: '#FFFFFF',
    fontSize: 12,
    fontWeight: '600',
  },
  dueDate: {
    fontSize: 12,
    color: '#6B7280',
  },
  overdue: {
    color: '#EF4444',
    fontWeight: '600',
  },
  deleteButton: {
    marginLeft: 8,
  },
  deleteIcon: {
    fontSize: 28,
    color: '#EF4444',
    fontWeight: '300',
  },
});

4.2 Composant Button personnalisé

// src/components/Button.tsx
import React from 'react';
import {
  TouchableOpacity,
  Text,
  StyleSheet,
  ActivityIndicator,
  ViewStyle,
  TextStyle,
} from 'react-native';

interface ButtonProps {
  title: string;
  onPress: () => void;
  variant?: 'primary' | 'secondary' | 'outline' | 'danger';
  size?: 'small' | 'medium' | 'large';
  loading?: boolean;
  disabled?: boolean;
  style?: ViewStyle;
  textStyle?: TextStyle;
  fullWidth?: boolean;
}

export const Button: React.FC<ButtonProps> = ({
  title,
  onPress,
  variant = 'primary',
  size = 'medium',
  loading = false,
  disabled = false,
  style,
  textStyle,
  fullWidth = false,
}) => {
  const buttonStyles = [
    styles.button,
    styles[${variant}Button],
    styles[${size}Button],
    fullWidth && styles.fullWidth,
    (disabled || loading) && styles.disabled,
    style,
  ];

  const textStyles = [
    styles.text,
    styles[${variant}Text],
    styles[${size}Text],
    textStyle,
  ];

  return (
    <TouchableOpacity
      style={buttonStyles}
      onPress={onPress}
      disabled={disabled || loading}
      activeOpacity={0.8}
    >
      {loading ? (
        <ActivityIndicator
          color={variant === 'outline' ? '#3B82F6' : '#FFFFFF'}
          size="small"
        />
      ) : (
        <Text style={textStyles}>{title}</Text>
      )}
    </TouchableOpacity>
  );
};

const styles = StyleSheet.create({
  button: {
    borderRadius: 8,
    alignItems: 'center',
    justifyContent: 'center',
  },
  fullWidth: {
    width: '100%',
  },
  disabled: {
    opacity: 0.5,
  },

  // Variants
  primaryButton: {
    backgroundColor: '#3B82F6',
  },
  secondaryButton: {
    backgroundColor: '#6B7280',
  },
  outlineButton: {
    backgroundColor: 'transparent',
    borderWidth: 2,
    borderColor: '#3B82F6',
  },
  dangerButton: {
    backgroundColor: '#EF4444',
  },

  // Sizes
  smallButton: {
    paddingHorizontal: 12,
    paddingVertical: 6,
  },
  mediumButton: {
    paddingHorizontal: 16,
    paddingVertical: 12,
  },
  largeButton: {
    paddingHorizontal: 24,
    paddingVertical: 16,
  },

  // Text styles
  text: {
    fontWeight: '600',
  },
  primaryText: {
    color: '#FFFFFF',
  },
  secondaryText: {
    color: '#FFFFFF',
  },
  outlineText: {
    color: '#3B82F6',
  },
  dangerText: {
    color: '#FFFFFF',
  },
  smallText: {
    fontSize: 14,
  },
  mediumText: {
    fontSize: 16,
  },
  largeText: {
    fontSize: 18,
  },
});

5. Navigation avec React Navigation

5.1 Configuration du navigateur

// src/navigation/AppNavigator.tsx
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { RootStackParamList } from '@/types/navigation';

// Screens
import TodoListScreen from '@/screens/TodoListScreen';
import TodoDetailScreen from '@/screens/TodoDetailScreen';
import AddTodoScreen from '@/screens/AddTodoScreen';
import EditTodoScreen from '@/screens/EditTodoScreen';
import SettingsScreen from '@/screens/SettingsScreen';

const Stack = createStackNavigator<RootStackParamList>();

export const AppNavigator: React.FC = () => {
  return (
    <NavigationContainer>
      <Stack.Navigator
        initialRouteName="TodoList"
        screenOptions={{
          headerStyle: {
            backgroundColor: '#3B82F6',
          },
          headerTintColor: '#FFFFFF',
          headerTitleStyle: {
            fontWeight: '600',
            fontSize: 18,
          },
          headerBackTitleVisible: false,
          cardStyleInterpolator: ({ current, layouts }) => {
            return {
              cardStyle: {
                transform: [
                  {
                    translateX: current.progress.interpolate({
                      inputRange: [0, 1],
                      outputRange: [layouts.screen.width, 0],
                    }),
                  },
                ],
              },
            };
          },
        }}
      >
        <Stack.Screen
          name="TodoList"
          component={TodoListScreen}
          options={{ title: 'Mes Tâches' }}
        />

        <Stack.Screen
          name="TodoDetail"
          component={TodoDetailScreen}
          options={{ title: 'Détail de la tâche' }}
        />

        <Stack.Screen
          name="AddTodo"
          component={AddTodoScreen}
          options={{ title: 'Nouvelle tâche' }}
        />

        <Stack.Screen
          name="EditTodo"
          component={EditTodoScreen}
          options={{ title: 'Modifier la tâche' }}
        />

        <Stack.Screen
          name="Settings"
          component={SettingsScreen}
          options={{ title: 'Paramètres' }}
        />
      </Stack.Navigator>
    </NavigationContainer>
  );
};

6. Écrans principaux

6.1 Écran Liste des tâches

// src/screens/TodoListScreen.tsx
import React, { useEffect, useState } from 'react';
import {
  View,
  FlatList,
  StyleSheet,
  Text,
  TouchableOpacity,
  RefreshControl,
  Alert,
  SafeAreaView,
} from 'react-native';
import { TodoListNavigationProp } from '@/types/navigation';
import { useTodoStore } from '@/store/todoStore';
import { TodoCard } from '@/components/TodoCard';
import { Button } from '@/components/Button';
import { Todo, TodoFilter, TodoSort } from '@/types/todo';

interface Props {
  navigation: TodoListNavigationProp;
}

const TodoListScreen: React.FC<Props> = ({ navigation }) => {
  const {
    loadTodos,
    deleteTodo,
    toggleComplete,
    getFilteredTodos,
    getStats,
    setFilter,
    setSortBy,
    filter,
    sortBy,
    loading,
    error,
    clearError,
  } = useTodoStore();

  const [refreshing, setRefreshing] = useState(false);

  useEffect(() => {
    loadTodos();
  }, []);

  useEffect(() => {
    if (error) {
      Alert.alert('Erreur', error, [
        { text: 'OK', onPress: clearError },
      ]);
    }
  }, [error]);

  const handleRefresh = async () => {
    setRefreshing(true);
    await loadTodos();
    setRefreshing(false);
  };

  const handleDeleteTodo = (id: string) => {
    Alert.alert(
      'Confirmation',
      'Voulez-vous vraiment supprimer cette tâche ?',
      [
        { text: 'Annuler', style: 'cancel' },
        {
          text: 'Supprimer',
          style: 'destructive',
          onPress: () => deleteTodo(id),
        },
      ]
    );
  };

  const todos = getFilteredTodos();
  const stats = getStats();

  const renderTodo = ({ item }: { item: Todo }) => (
    <TodoCard
      todo={item}
      onPress={() => navigation.navigate('TodoDetail', { todoId: item.id })}
      onToggleComplete={() => toggleComplete(item.id)}
      onDelete={() => handleDeleteTodo(item.id)}
    />
  );

  const renderHeader = () => (
    <View style={styles.header}>
      <View style={styles.statsContainer}>
        <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: '#10B981' }]}>
            {stats.completed}
          </Text>
          <Text style={styles.statLabel}>Terminées</Text>
        </View>

        <View style={styles.statItem}>
          <Text style={[styles.statValue, { color: '#3B82F6' }]}>
            {stats.active}
          </Text>
          <Text style={styles.statLabel}>Actives</Text>
        </View>

        {stats.overdue > 0 && (
          <View style={styles.statItem}>
            <Text style={[styles.statValue, { color: '#EF4444' }]}>
              {stats.overdue}
            </Text>
            <Text style={styles.statLabel}>En retard</Text>
          </View>
        )}
      </View>

      <View style={styles.filters}>
        <TouchableOpacity
          style={[styles.filterButton, filter === 'all' && styles.activeFilter]}
          onPress={() => setFilter('all')}
        >
          <Text
            style={[
              styles.filterText,
              filter === 'all' && styles.activeFilterText,
            ]}
          >
            Toutes
          </Text>
        </TouchableOpacity>

        <TouchableOpacity
          style={[styles.filterButton, filter === 'active' && styles.activeFilter]}
          onPress={() => setFilter('active')}
        >
          <Text
            style={[
              styles.filterText,
              filter === 'active' && styles.activeFilterText,
            ]}
          >
            Actives
          </Text>
        </TouchableOpacity>

        <TouchableOpacity
          style={[styles.filterButton, filter === 'completed' && styles.activeFilter]}
          onPress={() => setFilter('completed')}
        >
          <Text
            style={[
              styles.filterText,
              filter === 'completed' && styles.activeFilterText,
            ]}
          >
            Terminées
          </Text>
        </TouchableOpacity>
      </View>

      <View style={styles.sortContainer}>
        <Text style={styles.sortLabel}>Trier par :</Text>

        <TouchableOpacity
          style={[styles.sortButton, sortBy === 'date' && styles.activeSort]}
          onPress={() => setSortBy('date')}
        >
          <Text style={styles.sortText}>Date</Text>
        </TouchableOpacity>

        <TouchableOpacity
          style={[styles.sortButton, sortBy === 'priority' && styles.activeSort]}
          onPress={() => setSortBy('priority')}
        >
          <Text style={styles.sortText}>Priorité</Text>
        </TouchableOpacity>

        <TouchableOpacity
          style={[styles.sortButton, sortBy === 'alphabetical' && styles.activeSort]}
          onPress={() => setSortBy('alphabetical')}
        >
          <Text style={styles.sortText}>A-Z</Text>
        </TouchableOpacity>
      </View>
    </View>
  );

  const renderEmpty = () => (
    <View style={styles.emptyContainer}>
      <Text style={styles.emptyEmoji}>📝</Text>
      <Text style={styles.emptyTitle}>Aucune tâche</Text>
      <Text style={styles.emptyText}>
        {filter === 'all'
          ? 'Ajoutez votre première tâche'
          : filter === 'active'
          ? 'Aucune tâche active'
          : 'Aucune tâche terminée'}
      </Text>
    </View>
  );

  return (
    <SafeAreaView style={styles.container}>
      <FlatList
        data={todos}
        renderItem={renderTodo}
        keyExtractor={(item) => item.id}
        contentContainerStyle={styles.listContent}
        ListHeaderComponent={renderHeader}
        ListEmptyComponent={renderEmpty}
        refreshControl={
          <RefreshControl
            refreshing={refreshing}
            onRefresh={handleRefresh}
            tintColor="#3B82F6"
          />
        }
      />

      <View style={styles.fab}>
        <Button
          title="+ Nouvelle tâche"
          onPress={() => navigation.navigate('AddTodo', {})}
          size="large"
        />
      </View>
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#F3F4F6',
  },
  listContent: {
    padding: 16,
    paddingBottom: 80,
  },
  header: {
    marginBottom: 16,
  },
  statsContainer: {
    flexDirection: 'row',
    justifyContent: 'space-around',
    backgroundColor: '#FFFFFF',
    borderRadius: 12,
    padding: 16,
    marginBottom: 16,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 4,
    elevation: 3,
  },
  statItem: {
    alignItems: 'center',
  },
  statValue: {
    fontSize: 24,
    fontWeight: 'bold',
    color: '#1F2937',
  },
  statLabel: {
    fontSize: 12,
    color: '#6B7280',
    marginTop: 4,
  },
  filters: {
    flexDirection: 'row',
    gap: 8,
    marginBottom: 16,
  },
  filterButton: {
    flex: 1,
    paddingVertical: 8,
    paddingHorizontal: 12,
    backgroundColor: '#FFFFFF',
    borderRadius: 8,
    borderWidth: 1,
    borderColor: '#E5E7EB',
    alignItems: 'center',
  },
  activeFilter: {
    backgroundColor: '#3B82F6',
    borderColor: '#3B82F6',
  },
  filterText: {
    fontSize: 14,
    color: '#6B7280',
    fontWeight: '500',
  },
  activeFilterText: {
    color: '#FFFFFF',
  },
  sortContainer: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 8,
  },
  sortLabel: {
    fontSize: 14,
    color: '#6B7280',
    fontWeight: '500',
  },
  sortButton: {
    paddingVertical: 6,
    paddingHorizontal: 12,
    backgroundColor: '#E5E7EB',
    borderRadius: 6,
  },
  activeSort: {
    backgroundColor: '#3B82F6',
  },
  sortText: {
    fontSize: 12,
    color: '#1F2937',
    fontWeight: '500',
  },
  emptyContainer: {
    alignItems: 'center',
    justifyContent: 'center',
    paddingVertical: 60,
  },
  emptyEmoji: {
    fontSize: 64,
    marginBottom: 16,
  },
  emptyTitle: {
    fontSize: 20,
    fontWeight: '600',
    color: '#1F2937',
    marginBottom: 8,
  },
  emptyText: {
    fontSize: 14,
    color: '#6B7280',
  },
  fab: {
    position: 'absolute',
    bottom: 16,
    left: 16,
    right: 16,
  },
});

export default TodoListScreen;

6.2 Écran d’ajout de tâche

// src/screens/AddTodoScreen.tsx
import React, { useState } from 'react';
import {
  View,
  TextInput,
  StyleSheet,
  ScrollView,
  Text,
  TouchableOpacity,
  Alert,
  Platform,
  SafeAreaView,
  KeyboardAvoidingView,
} from 'react-native';
import DateTimePicker from '@react-native-community/datetimepicker';
import { AddTodoNavigationProp } from '@/types/navigation';
import { useTodoStore } from '@/store/todoStore';
import { Button } from '@/components/Button';
import { Todo } from '@/types/todo';

interface Props {
  navigation: AddTodoNavigationProp;
}

const AddTodoScreen: React.FC<Props> = ({ navigation }) => {
  const { addTodo, loading } = useTodoStore();

  const [title, setTitle] = useState('');
  const [description, setDescription] = useState('');
  const [priority, setPriority] = useState<Todo['priority']>('medium');
  const [dueDate, setDueDate] = useState<Date | null>(null);
  const [showDatePicker, setShowDatePicker] = useState(false);
  const [category, setCategory] = useState('Personnel');

  const categories = ['Personnel', 'Travail', 'Shopping', 'Santé'];

  const handleSubmit = async () => {
    if (!title.trim()) {
      Alert.alert('Erreur', 'Le titre est obligatoire');
      return;
    }

    try {
      await addTodo(
        title.trim(),
        description.trim(),
        priority,
        dueDate ? dueDate.toISOString() : null,
        category
      );

      Alert.alert('Succès', 'Tâche ajoutée avec succès', [
        { text: 'OK', onPress: () => navigation.goBack() },
      ]);
    } catch (error) {
      Alert.alert('Erreur', 'Impossible d'ajouter la tâche');
    }
  };

  const handleDateChange = (event: any, selectedDate?: Date) => {
    setShowDatePicker(Platform.OS === 'ios');

    if (selectedDate) {
      setDueDate(selectedDate);
    }
  };

  return (
    <SafeAreaView style={styles.container}>
      <KeyboardAvoidingView
        behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
        style={styles.flex}
      >
        <ScrollView
          contentContainerStyle={styles.content}
          keyboardShouldPersistTaps="handled"
        >
          <View style={styles.section}>
            <Text style={styles.label}>Titre *</Text>
            <TextInput
              style={styles.input}
              value={title}
              onChangeText={setTitle}
              placeholder="Titre de la tâche"
              placeholderTextColor="#9CA3AF"
              autoFocus
            />
          </View>

          <View style={styles.section}>
            <Text style={styles.label}>Description</Text>
            <TextInput
              style={[styles.input, styles.textArea]}
              value={description}
              onChangeText={setDescription}
              placeholder="Description détaillée"
              placeholderTextColor="#9CA3AF"
              multiline
              numberOfLines={4}
              textAlignVertical="top"
            />
          </View>

          <View style={styles.section}>
            <Text style={styles.label}>Priorité</Text>
            <View style={styles.priorityContainer}>
              <TouchableOpacity
                style={[
                  styles.priorityButton,
                  priority === 'low' && { backgroundColor: '#10B981' },
                ]}
                onPress={() => setPriority('low')}
              >
                <Text
                  style={[
                    styles.priorityText,
                    priority === 'low' && styles.priorityTextActive,
                  ]}
                >
                  Basse
                </Text>
              </TouchableOpacity>

              <TouchableOpacity
                style={[
                  styles.priorityButton,
                  priority === 'medium' && { backgroundColor: '#F59E0B' },
                ]}
                onPress={() => setPriority('medium')}
              >
                <Text
                  style={[
                    styles.priorityText,
                    priority === 'medium' && styles.priorityTextActive,
                  ]}
                >
                  Moyenne
                </Text>
              </TouchableOpacity>

              <TouchableOpacity
                style={[
                  styles.priorityButton,
                  priority === 'high' && { backgroundColor: '#EF4444' },
                ]}
                onPress={() => setPriority('high')}
              >
                <Text
                  style={[
                    styles.priorityText,
                    priority === 'high' && styles.priorityTextActive,
                  ]}
                >
                  Haute
                </Text>
              </TouchableOpacity>
            </View>
          </View>

          <View style={styles.section}>
            <Text style={styles.label}>Catégorie</Text>
            <View style={styles.categoryContainer}>
              {categories.map((cat) => (
                <TouchableOpacity
                  key={cat}
                  style={[
                    styles.categoryButton,
                    category === cat && styles.categoryButtonActive,
                  ]}
                  onPress={() => setCategory(cat)}
                >
                  <Text
                    style={[
                      styles.categoryText,
                      category === cat && styles.categoryTextActive,
                    ]}
                  >
                    {cat}
                  </Text>
                </TouchableOpacity>
              ))}
            </View>
          </View>

          <View style={styles.section}>
            <Text style={styles.label}>Date d'échéance</Text>
            <TouchableOpacity
              style={styles.dateButton}
              onPress={() => setShowDatePicker(true)}
            >
              <Text style={styles.dateButtonText}>
                {dueDate
                  ? dueDate.toLocaleDateString('fr-BE', {
                      day: 'numeric',
                      month: 'long',
                      year: 'numeric',
                    })
                  : 'Sélectionner une date'}
              </Text>
            </TouchableOpacity>

            {dueDate && (
              <TouchableOpacity
                style={styles.clearDateButton}
                onPress={() => setDueDate(null)}
              >
                <Text style={styles.clearDateText}>Supprimer la date</Text>
              </TouchableOpacity>
            )}

            {showDatePicker && (
              <DateTimePicker
                value={dueDate || new Date()}
                mode="date"
                display={Platform.OS === 'ios' ? 'spinner' : 'default'}
                onChange={handleDateChange}
                minimumDate={new Date()}
              />
            )}
          </View>

          <View style={styles.actions}>
            <Button
              title="Annuler"
              onPress={() => navigation.goBack()}
              variant="outline"
              style={styles.actionButton}
            />

            <Button
              title="Ajouter"
              onPress={handleSubmit}
              loading={loading}
              style={styles.actionButton}
            />
          </View>
        </ScrollView>
      </KeyboardAvoidingView>
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#F3F4F6',
  },
  flex: {
    flex: 1,
  },
  content: {
    padding: 16,
  },
  section: {
    marginBottom: 24,
  },
  label: {
    fontSize: 16,
    fontWeight: '600',
    color: '#1F2937',
    marginBottom: 8,
  },
  input: {
    backgroundColor: '#FFFFFF',
    borderRadius: 8,
    padding: 12,
    fontSize: 16,
    color: '#1F2937',
    borderWidth: 1,
    borderColor: '#E5E7EB',
  },
  textArea: {
    minHeight: 100,
    paddingTop: 12,
  },
  priorityContainer: {
    flexDirection: 'row',
    gap: 12,
  },
  priorityButton: {
    flex: 1,
    paddingVertical: 12,
    paddingHorizontal: 16,
    backgroundColor: '#FFFFFF',
    borderRadius: 8,
    borderWidth: 1,
    borderColor: '#E5E7EB',
    alignItems: 'center',
  },
  priorityText: {
    fontSize: 14,
    fontWeight: '600',
    color: '#6B7280',
  },
  priorityTextActive: {
    color: '#FFFFFF',
  },
  categoryContainer: {
    flexDirection: 'row',
    flexWrap: 'wrap',
    gap: 8,
  },
  categoryButton: {
    paddingVertical: 8,
    paddingHorizontal: 16,
    backgroundColor: '#FFFFFF',
    borderRadius: 8,
    borderWidth: 1,
    borderColor: '#E5E7EB',
  },
  categoryButtonActive: {
    backgroundColor: '#3B82F6',
    borderColor: '#3B82F6',
  },
  categoryText: {
    fontSize: 14,
    fontWeight: '500',
    color: '#6B7280',
  },
  categoryTextActive: {
    color: '#FFFFFF',
  },
  dateButton: {
    backgroundColor: '#FFFFFF',
    borderRadius: 8,
    padding: 12,
    borderWidth: 1,
    borderColor: '#E5E7EB',
  },
  dateButtonText: {
    fontSize: 16,
    color: '#1F2937',
  },
  clearDateButton: {
    marginTop: 8,
    alignSelf: 'flex-start',
  },
  clearDateText: {
    fontSize: 14,
    color: '#EF4444',
  },
  actions: {
    flexDirection: 'row',
    gap: 12,
    marginTop: 16,
  },
  actionButton: {
    flex: 1,
  },
});

export default AddTodoScreen;

7. Lancement de l’application

7.1 Démarrage de l’app

# iOS (macOS uniquement)
npm run ios
# ou pour un simulateur spécifique
npx react-native run-ios --simulator="iPhone 15 Pro"

# Android
npm run android
# ou pour un émulateur spécifique
npx react-native run-android --deviceId=emulator-5554

# Démarrer Metro Bundler séparément
npm start

7.2 Débogage

// Configuration Reactotron (optionnel)
// npm install --save-dev reactotron-react-native

// ReactotronConfig.ts
import Reactotron from 'reactotron-react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';

if (__DEV__) {
  Reactotron
    .setAsyncStorageHandler(AsyncStorage)
    .configure({
      name: 'TodoApp',
    })
    .useReactNative({
      asyncStorage: true,
      networking: {
        ignoreUrls: /symbolicate/,
      },
      editor: false,
      errors: { veto: (stackFrame) => false },
      overlay: false,
    })
    .connect();
}

export default Reactotron;

7.3 Tests avec Jest

// __tests__/TodoCard.test.tsx
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import { TodoCard } from '@/components/TodoCard';
import { Todo } from '@/types/todo';

const mockTodo: Todo = {
  id: '1',
  title: 'Test Todo',
  description: 'Test Description',
  completed: false,
  priority: 'medium',
  dueDate: null,
  category: 'Personnel',
  createdAt: new Date().toISOString(),
  updatedAt: new Date().toISOString(),
};

describe('TodoCard', () => {
  it('affiche correctement le titre et la description', () => {
    const { getByText } = render(
      <TodoCard
        todo={mockTodo}
        onPress={jest.fn()}
        onToggleComplete={jest.fn()}
        onDelete={jest.fn()}
      />
    );

    expect(getByText('Test Todo')).toBeTruthy();
    expect(getByText('Test Description')).toBeTruthy();
  });

  it('appelle onToggleComplete quand on clique sur la checkbox', () => {
    const onToggleComplete = jest.fn();

    const { getByTestId } = render(
      <TodoCard
        todo={mockTodo}
        onPress={jest.fn()}
        onToggleComplete={onToggleComplete}
        onDelete={jest.fn()}
      />
    );

    fireEvent.press(getByTestId('checkbox'));
    expect(onToggleComplete).toHaveBeenCalledTimes(1);
  });
});

8. Build et déploiement

8.1 Build Android (APK)

# Générer une clé de signature (première fois uniquement)
cd android/app
keytool -genkeypair -v -storetype PKCS12 -keystore todo-app-release.keystore -alias todo-app -keyalg RSA -keysize 2048 -validity 10000

# Configurer gradle.properties
# android/gradle.properties
MYAPP_RELEASE_STORE_FILE=todo-app-release.keystore
MYAPP_RELEASE_KEY_ALIAS=todo-app
MYAPP_RELEASE_STORE_PASSWORD=your_password
MYAPP_RELEASE_KEY_PASSWORD=your_password

# Build release
cd android
./gradlew assembleRelease

# APK disponible dans :
# android/app/build/outputs/apk/release/app-release.apk

8.2 Build iOS (IPA)

# Ouvrir Xcode
open ios/TodoApp.xcworkspace

# Dans Xcode :
# 1. Sélectionner le schéma "TodoApp" > "Generic iOS Device"
# 2. Product > Archive
# 3. Distribute App > App Store Connect
# 4. Suivre l'assistant de publication

Conclusion

Vous avez maintenant une application React Native complète et fonctionnelle. Ce guide couvre les fondamentaux du développement mobile avec React Native : configuration, navigation, gestion d’état, stockage local et interface utilisateur.

Prochaines étapes :

  • Ajouter des tests end-to-end avec Detox
  • Intégrer des notifications push
  • Implémenter la synchronisation cloud
  • Optimiser les performances avec React.memo et useMemo
  • Ajouter l’internationalisation (i18n)
  • Ressources utiles :**

  • Documentation officielle : https://reactnative.dev
  • React Navigation : https://reactnavigation.org
  • Awesome React Native : https://github.com/jondot/awesome-react-native
  • Continuez à pratiquer et à explorer l’écosystème React Native pour créer des applications mobiles professionnelles.

    À propos de l'auteur

    Pascal

    Pascal — SRE & Observability Engineer. J'applique les pratiques SRE (SLI/SLO, alerting, observabilité) à mon homelab Proxmox, avec les mêmes exigences qu'en production.

    Une remarque, un retour ?

    Cet article est vivant - corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisis celui qui te convient.

    Laisser un commentaire