Flutter : Créer des applications iOS et Android avec Dart – Guide complet 2025
Flutter, développé par Google, permet de créer des applications natives pour iOS, Android, web et…
Le choix de la technologie pour développer une application mobile est crucial. Cette décision impacte les performances, le budget, le délai de mise sur le marché et la maintenabilité à long terme. Analysons les trois approches principales avec des exemples concrets et des critères de décision professionnels.
Le développement natif utilise les langages et outils officiels de chaque plateforme :
iOS :
Android :
Swift (iOS) – UIKit + MVVM :
// Task.swift - Modèle de données
import Foundation
struct Task: Identifiable, Codable {
let id: UUID
var title: String
var description: String
var isCompleted: Bool
var createdAt: Date
init(title: String, description: String) {
self.id = UUID()
self.title = title
self.description = description
self.isCompleted = false
self.createdAt = Date()
}
}
// TaskViewModel.swift - Logique métier
import Foundation
import Combine
class TaskViewModel: ObservableObject {
@Published var tasks: [Task] = []
@Published var isLoading: Bool = false
@Published var errorMessage: String?
private let repository: TaskRepository
private var cancellables = Set()
init(repository: TaskRepository = TaskRepository()) {
self.repository = repository
loadTasks()
}
func loadTasks() {
isLoading = true
repository.fetchTasks()
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.errorMessage = error.localizedDescription
}
} receiveValue: { [weak self] tasks in
self?.tasks = tasks
}
.store(in: &cancellables)
}
func addTask(title: String, description: String) {
let task = Task(title: title, description: description)
repository.saveTask(task)
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
if case .failure(let error) = completion {
self?.errorMessage = error.localizedDescription
}
} receiveValue: { [weak self] savedTask in
self?.tasks.append(savedTask)
}
.store(in: &cancellables)
}
func toggleTask( task: Task) {
guard let index = tasks.firstIndex(where: { $0.id == task.id }) else {
return
}
tasks[index].isCompleted.toggle()
repository.updateTask(tasks[index])
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
if case .failure(let error) = completion {
self?.errorMessage = error.localizedDescription
// Rollback en cas d'erreur
self?.tasks[index].isCompleted.toggle()
}
} receiveValue: { in
// Mise à jour réussie
}
.store(in: &cancellables)
}
func deleteTask(at offsets: IndexSet) {
let tasksToDelete = offsets.map { tasks[$0] }
tasksToDelete.forEach { task in
repository.deleteTask(task)
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
if case .failure(let error) = completion {
self?.errorMessage = error.localizedDescription
}
} receiveValue: { [weak self] in
self?.tasks.removeAll { $0.id == task.id }
}
.store(in: &cancellables)
}
}
}
// TaskRepository.swift - Couche de données
import Foundation
import Combine
class TaskRepository {
private let userDefaults = UserDefaults.standard
private let tasksKey = "savedtasks"
func fetchTasks() -> AnyPublisher<[Task], Error> {
return Future { promise in
// Simulation d'une latence réseau
DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) {
guard let data = self.userDefaults.data(forKey: self.tasksKey) else {
promise(.success([]))
return
}
do {
let tasks = try JSONDecoder().decode([Task].self, from: data)
promise(.success(tasks))
} catch {
promise(.failure(error))
}
}
}
.eraseToAnyPublisher()
}
func saveTask( task: Task) -> AnyPublisher {
return Future { promise in
self.fetchTasks()
.sink { completion in
if case .failure(let error) = completion {
promise(.failure(error))
}
} receiveValue: { tasks in
var updatedTasks = tasks
updatedTasks.append(task)
do {
let data = try JSONEncoder().encode(updatedTasks)
self.userDefaults.set(data, forKey: self.tasksKey)
promise(.success(task))
} catch {
promise(.failure(error))
}
}
.store(in: &Set())
}
.eraseToAnyPublisher()
}
func updateTask( task: Task) -> AnyPublisher {
return Future { promise in
self.fetchTasks()
.sink { completion in
if case .failure(let error) = completion {
promise(.failure(error))
}
} receiveValue: { tasks in
var updatedTasks = tasks
if let index = updatedTasks.firstIndex(where: { $0.id == task.id }) {
updatedTasks[index] = task
do {
let data = try JSONEncoder().encode(updatedTasks)
self.userDefaults.set(data, forKey: self.tasksKey)
promise(.success(task))
} catch {
promise(.failure(error))
}
} else {
promise(.failure(NSError(domain: "TaskRepository", code: 404)))
}
}
.store(in: &Set())
}
.eraseToAnyPublisher()
}
func deleteTask( task: Task) -> AnyPublisher {
return Future { promise in
self.fetchTasks()
.sink { completion in
if case .failure(let error) = completion {
promise(.failure(error))
}
} receiveValue: { tasks in
let updatedTasks = tasks.filter { $0.id != task.id }
do {
let data = try JSONEncoder().encode(updatedTasks)
self.userDefaults.set(data, forKey: self.tasksKey)
promise(.success(()))
} catch {
promise(.failure(error))
}
}
.store(in: &Set())
}
.eraseToAnyPublisher()
}
}
// TaskListView.swift - Interface SwiftUI
import SwiftUI
struct TaskListView: View {
@StateObject private var viewModel = TaskViewModel()
@State private var showingAddTask = false
var body: some View {
NavigationView {
ZStack {
if viewModel.isLoading {
ProgressView("Chargement...")
} else if viewModel.tasks.isEmpty {
emptyStateView
} else {
taskListView
}
}
.navigationTitle("Mes tâches")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { showingAddTask = true }) {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddTask) {
AddTaskView(viewModel: viewModel)
}
.alert("Erreur", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") {
viewModel.errorMessage = nil
}
} message: {
if let error = viewModel.errorMessage {
Text(error)
}
}
}
}
private var emptyStateView: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle")
.font(.system(size: 64))
.foregroundColor(.gray)
Text("Aucune tâche")
.font(.headline)
Text("Ajoutez votre première tâche")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
private var taskListView: some View {
List {
ForEach(viewModel.tasks) { task in
TaskRowView(task: task) {
viewModel.toggleTask(task)
}
}
.onDelete(perform: viewModel.deleteTask)
}
.listStyle(.insetGrouped)
}
}
struct TaskRowView: View {
let task: Task
let onToggle: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 12) {
Button(action: onToggle) {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.font(.title2)
.foregroundColor(task.isCompleted ? .green : .gray)
}
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 4) {
Text(task.title)
.font(.headline)
.strikethrough(task.isCompleted)
.foregroundColor(task.isCompleted ? .secondary : .primary)
Text(task.description)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(2)
Text(task.createdAt, style: .date)
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 4)
}
}
struct AddTaskView: View {
@Environment(.dismiss) var dismiss
@ObservedObject var viewModel: TaskViewModel
@State private var title = ""
@State private var description = ""
var body: some View {
NavigationView {
Form {
Section {
TextField("Titre", text: $title)
TextEditor(text: $description)
.frame(height: 100)
}
Section {
Button("Ajouter") {
viewModel.addTask(title: title, description: description)
dismiss()
}
.disabled(title.isEmpty)
}
}
.navigationTitle("Nouvelle tâche")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Annuler") {
dismiss()
}
}
}
}
}
}
Kotlin (Android) – Jetpack Compose + MVVM :
// Task.kt - Modèle de données
package com.example.tasks.data
import androidx.room.
import java.util.
@Entity(tableName = "tasks")
data class Task(
@PrimaryKey
val id: String = UUID.randomUUID().toString(),
val title: String,
val description: String,
val isCompleted: Boolean = false,
val createdAt: Long = System.currentTimeMillis()
)
// TaskDao.kt - Accès aux données
@Dao
interface TaskDao {
@Query("SELECT FROM tasks ORDER BY createdAt DESC")
fun getAllTasks(): kotlinx.coroutines.flow.Flow>
@Query("SELECT
FROM tasks WHERE id = :taskId")
suspend fun getTaskById(taskId: String): Task?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTask(task: Task)
@Update
suspend fun updateTask(task: Task)
@Delete
suspend fun deleteTask(task: Task)
@Query("DELETE FROM tasks WHERE id = :taskId")
suspend fun deleteTaskById(taskId: String)
}
// AppDatabase.kt - Base de données Room
@Database(entities = [Task::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: android.content.Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"taskdatabase"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}
}
}
}
// TaskRepository.kt - Couche de données
package com.example.tasks.repository
import com.example.tasks.data.Task
import com.example.tasks.data.TaskDao
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TaskRepository @Inject constructor(
private val taskDao: TaskDao
) {
val allTasks: Flow> = taskDao.getAllTasks()
suspend fun getTaskById(taskId: String): Task? {
return taskDao.getTaskById(taskId)
}
suspend fun insertTask(task: Task) {
taskDao.insertTask(task)
}
suspend fun updateTask(task: Task) {
taskDao.updateTask(task)
}
suspend fun deleteTask(task: Task) {
taskDao.deleteTask(task)
}
suspend fun toggleTaskCompletion(task: Task) {
val updatedTask = task.copy(isCompleted = !task.isCompleted)
taskDao.updateTask(updatedTask)
}
}
// TaskViewModel.kt - Logique métier
package com.example.tasks.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.tasks.data.Task
import com.example.tasks.repository.TaskRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.
import kotlinx.coroutines.launch
import javax.inject.Inject
data class TaskUiState(
val tasks: List = emptyList(),
val isLoading: Boolean = false,
val errorMessage: String? = null
)
@HiltViewModel
class TaskViewModel @Inject constructor(
private val repository: TaskRepository
) : ViewModel() {
private val uiState = MutableStateFlow(TaskUiState(isLoading = true))
val uiState: StateFlow = uiState.asStateFlow()
init {
loadTasks()
}
private fun loadTasks() {
viewModelScope.launch {
repository.allTasks
.catch { exception ->
uiState.update {
it.copy(
isLoading = false,
errorMessage = exception.message
)
}
}
.collect { tasks ->
uiState.update {
it.copy(
tasks = tasks,
isLoading = false,
errorMessage = null
)
}
}
}
}
fun addTask(title: String, description: String) {
viewModelScope.launch {
try {
val task = Task(
title = title,
description = description
)
repository.insertTask(task)
} catch (e: Exception) {
uiState.update { it.copy(errorMessage = e.message) }
}
}
}
fun toggleTask(task: Task) {
viewModelScope.launch {
try {
repository.toggleTaskCompletion(task)
} catch (e: Exception) {
uiState.update { it.copy(errorMessage = e.message) }
}
}
}
fun deleteTask(task: Task) {
viewModelScope.launch {
try {
repository.deleteTask(task)
} catch (e: Exception) {
uiState.update { it.copy(errorMessage = e.message) }
}
}
}
fun clearError() {
uiState.update { it.copy(errorMessage = null) }
}
}
// TaskScreen.kt - Interface Jetpack Compose
package com.example.tasks.ui
import androidx.compose.animation.
import androidx.compose.foundation.layout.
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.
import androidx.compose.material3.
import androidx.compose.runtime.
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.tasks.data.Task
import com.example.tasks.viewmodel.TaskViewModel
import java.text.SimpleDateFormat
import java.util.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskScreen(
viewModel: TaskViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
var showAddDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Mes tâches") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { showAddDialog = true }
) {
Icon(Icons.Default.Add, contentDescription = "Ajouter une tâche")
}
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
when {
uiState.isLoading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
uiState.tasks.isEmpty() -> {
EmptyStateView(
modifier = Modifier.align(Alignment.Center)
)
}
else -> {
TaskList(
tasks = uiState.tasks,
onToggleTask = viewModel::toggleTask,
onDeleteTask = viewModel::deleteTask
)
}
}
}
if (showAddDialog) {
AddTaskDialog(
onDismiss = { showAddDialog = false },
onConfirm = { title, description ->
viewModel.addTask(title, description)
showAddDialog = false
}
)
}
uiState.errorMessage?.let { error ->
LaunchedEffect(error) {
// Afficher un Snackbar ou un Toast
viewModel.clearError()
}
}
}
}
@Composable
fun TaskList(
tasks: List,
onToggleTask: (Task) -> Unit,
onDeleteTask: (Task) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(tasks, key = { it.id }) { task ->
TaskItem(
task = task,
onToggle = { onToggleTask(task) },
onDelete = { onDeleteTask(task) }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskItem(
task: Task,
onToggle: () -> Unit,
onDelete: () -> Unit
) {
val dismissState = rememberDismissState(
confirmValueChange = {
if (it == DismissValue.DismissedToStart) {
onDelete()
true
} else {
false
}
}
)
SwipeToDismiss(
state = dismissState,
background = {
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp),
contentAlignment = Alignment.CenterEnd
) {
Icon(
Icons.Default.Delete,
contentDescription = "Supprimer",
tint = MaterialTheme.colorScheme.error
)
}
},
dismissContent = {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = task.isCompleted,
onCheckedChange = { onToggle() }
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = task.title,
style = MaterialTheme.typography.titleMedium,
textDecoration = if (task.isCompleted) {
TextDecoration.LineThrough
} else {
TextDecoration.None
}
)
Text(
text = task.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
Text(
text = formatDate(task.createdAt),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
)
}
@Composable
fun EmptyStateView(modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"Aucune tâche",
style = MaterialTheme.typography.headlineSmall
)
Text(
"Ajoutez votre première tâche",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
fun AddTaskDialog(
onDismiss: () -> Unit,
onConfirm: (String, String) -> Unit
) {
var title by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Nouvelle tâche") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Titre") },
singleLine = true
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description") },
minLines = 3,
maxLines = 5
)
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(title, description) },
enabled = title.isNotBlank()
) {
Text("Ajouter")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Annuler")
}
}
)
}
private fun formatDate(timestamp: Long): String {
val sdf = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.getDefault())
return sdf.format(Date(timestamp))
}
Performance optimale :
Accès complet aux fonctionnalités :
Expérience utilisateur native :
Coût de développement élevé :
Temps de développement :
Complexité de maintenance :
React Native (JavaScript/TypeScript) :
Flutter (Dart) :
// types.ts - Types TypeScript export interface WeatherData { location: string; temperature: number; condition: string; humidity: number; windSpeed: number; icon: string; forecast: ForecastDay[]; } export interface ForecastDay { date: string; maxTemp: number; minTemp: number; condition: string; icon: string; } export interface LocationCoordinates { latitude: number; longitude: number; } // api/weatherService.ts - Service API import axios from 'axios'; import { WeatherData } from '../types'; const APIKEY = 'yourapikey'; // À remplacer par votre clé const BASEURL = 'https://api.weatherapi.com/v1'; class WeatherService { async getWeatherByCoords( latitude: number, longitude: number ): Promise}} style={styles.icon} resizeMode="contain" />{ try { const response = await axios.get( ${BASEURL}/forecast.json, { params: { key: APIKEY, q:${latitude},${longitude}, days: 7, aqi: 'no', alerts: 'no', }, timeout: 10000, }); return this.mapResponseToWeatherData(response.data); } catch (error) { if (axios.isAxiosError(error)) { throw new Error( error.response?.data?.error?.message || 'Erreur de connexion au service météo' ); } throw error; } } async getWeatherByCity(city: string): Promise{ try { const response = await axios.get( ${BASEURL}/forecast.json, { params: { key: APIKEY, q: city, days: 7, aqi: 'no', alerts: 'no', }, timeout: 10000, }); return this.mapResponseToWeatherData(response.data); } catch (error) { if (axios.isAxiosError(error)) { throw new Error( error.response?.data?.error?.message || 'Ville introuvable' ); } throw error; } } private mapResponseToWeatherData(data: any): WeatherData { return { location:${data.location.name}, ${data.location.country}, temperature: Math.round(data.current.tempc), condition: data.current.condition.text, humidity: data.current.humidity, windSpeed: Math.round(data.current.windkph), icon: data.current.condition.icon, forecast: data.forecast.forecastday.map((day: any) => ({ date: day.date, maxTemp: Math.round(day.day.maxtempc), minTemp: Math.round(day.day.mintempc), condition: day.day.condition.text, icon: day.day.condition.icon, })), }; } } export default new WeatherService(); // hooks/useWeather.ts - Hook personnalisé import { useState, useEffect, useCallback } from 'react'; import { WeatherData, LocationCoordinates } from '../types'; import weatherService from '../api/weatherService'; import Geolocation from '@react-native-community/geolocation'; import { Platform, PermissionsAndroid } from 'react-native'; export const useWeather = () => { const [weather, setWeather] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState (null); const requestLocationPermission = async (): Promise => { if (Platform.OS === 'ios') { return true; // Géré par Info.plist } try { const granted = await PermissionsAndroid.request( PermissionsAndroid.PERMISSIONS.ACCESSFINELOCATION, { title: 'Permission de localisation', message: 'Cette application a besoin d'accéder à votre position', buttonNeutral: 'Plus tard', buttonNegative: 'Refuser', buttonPositive: 'Autoriser', } ); return granted === PermissionsAndroid.RESULTS.GRANTED; } catch (err) { console.error('Erreur permission:', err); return false; } }; const getCurrentLocation = (): Promise => { return new Promise((resolve, reject) => { Geolocation.getCurrentPosition( (position) => { resolve({ latitude: position.coords.latitude, longitude: position.coords.longitude, }); }, (error) => { reject(new Error('Impossible d'obtenir la position')); }, { enableHighAccuracy: true, timeout: 15000, maximumAge: 10000 } ); }); }; const fetchWeatherByLocation = useCallback(async () => { setLoading(true); setError(null); try { const hasPermission = await requestLocationPermission(); if (!hasPermission) { throw new Error('Permission de localisation refusée'); } const location = await getCurrentLocation(); const data = await weatherService.getWeatherByCoords( location.latitude, location.longitude ); setWeather(data); } catch (err) { setError(err instanceof Error ? err.message : 'Erreur inconnue'); } finally { setLoading(false); } }, []); const fetchWeatherByCity = useCallback(async (city: string) => { setLoading(true); setError(null); try { const data = await weatherService.getWeatherByCity(city); setWeather(data); } catch (err) { setError(err instanceof Error ? err.message : 'Erreur inconnue'); } finally { setLoading(false); } }, []); const refresh = useCallback(() => { if (weather) { fetchWeatherByLocation(); } }, [weather, fetchWeatherByLocation]); return { weather, loading, error, fetchWeatherByLocation, fetchWeatherByCity, refresh, }; }; // components/WeatherCard.tsx - Composant UI import React from 'react'; import { View, Text, Image, StyleSheet, Dimensions, } from 'react-native'; import { WeatherData } from '../types'; interface WeatherCardProps { weather: WeatherData; } const { width } = Dimensions.get('window'); export const WeatherCard: React.FC = ({ weather }) => { return ( {weather.location} https:${weather.icon} {weather.temperature}°C {weather.condition} ); }; const styles = StyleSheet.create({ card: { backgroundColor: '#FFFFFF', borderRadius: 20, padding: 24, marginHorizontal: 16, marginTop: 20, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 8, elevation: 5, }, location: { fontSize: 24, fontWeight: '600', color: '#1F2937', textAlign: 'center', marginBottom: 16, }, mainInfo: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginBottom: 12, }, icon: { width: 100, height: 100, }, temperature: { fontSize: 64, fontWeight: '700', color: '#1F2937', marginLeft: 8, }, condition: { fontSize: 18, color: '#6B7280', textAlign: 'center', marginBottom: 24, }, details: { flexDirection: 'row', justifyContent: 'space-around', borderTopWidth: 1, borderTopColor: '#E5E7EB', paddingTop: 16, }, detailItem: { alignItems: 'center', }, detailLabel: { fontSize: 14, color: '#6B7280', marginBottom: 4, }, detailValue: { fontSize: 18, fontWeight: '600', color: '#1F2937', }, }); // components/ForecastList.tsx - Liste des prévisions import React from 'react'; import { View, Text, Image, FlatList, StyleSheet, } from 'react-native'; import { ForecastDay } from '../types'; interface ForecastListProps { forecast: ForecastDay[]; } export const ForecastList: React.FC Humidité {weather.humidity}% Vent {weather.windSpeed} km/h = ({ forecast }) => { const renderItem = ({ item }: { item: ForecastDay }) => { const date = new Date(item.date); const dayName = date.toLocaleDateString('fr-FR', { weekday: 'short' }); return ( ); }; return ( {dayName} https:${item.icon} }} style={styles.forecastIcon} /> {item.maxTemp}° {item.minTemp}° ); }; const styles = StyleSheet.create({ container: { marginTop: 24, marginHorizontal: 16, }, title: { fontSize: 20, fontWeight: '600', color: '#1F2937', marginBottom: 16, }, list: { paddingRight: 16, }, forecastItem: { backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginRight: 12, alignItems: 'center', minWidth: 100, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 4, elevation: 2, }, dayName: { fontSize: 16, fontWeight: '600', color: '#1F2937', marginBottom: 8, textTransform: 'capitalize', }, forecastIcon: { width: 48, height: 48, marginBottom: 8, }, tempRange: { flexDirection: 'row', gap: 8, }, maxTemp: { fontSize: 16, fontWeight: '600', color: '#1F2937', }, minTemp: { fontSize: 16, color: '#6B7280', }, }); // screens/WeatherScreen.tsx - Écran principal import React, { useEffect, useState } from 'react'; import { View, StyleSheet, SafeAreaView, TextInput, TouchableOpacity, Text, ActivityIndicator, ScrollView, RefreshControl, KeyboardAvoidingView, Platform, } from 'react-native'; import { WeatherCard } from '../components/WeatherCard'; import { ForecastList } from '../components/ForecastList'; import { useWeather } from '../hooks/useWeather'; export const WeatherScreen: React.FC = () => { const [searchCity, setSearchCity] = useState(''); const { weather, loading, error, fetchWeatherByLocation, fetchWeatherByCity, refresh, } = useWeather(); useEffect(() => { fetchWeatherByLocation(); }, [fetchWeatherByLocation]); const handleSearch = () => { if (searchCity.trim()) { fetchWeatherByCity(searchCity.trim()); setSearchCity(''); } }; return ( Prévisions 7 jours item.date} horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.list} /> ); }; const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#F3F4F6', }, flex: { flex: 1, }, searchContainer: { flexDirection: 'row', padding: 16, gap: 8, }, searchInput: { flex: 1, backgroundColor: '#FFFFFF', borderRadius: 12, paddingHorizontal: 16, paddingVertical: 12, fontSize: 16, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 4, elevation: 2, }, searchButton: { backgroundColor: '#3B82F6', borderRadius: 12, paddingHorizontal: 20, justifyContent: 'center', alignItems: 'center', }, searchButtonText: { fontSize: 20, }, content: { flex: 1, }, contentContainer: { paddingBottom: 24, }, loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', marginTop: 100, }, loadingText: { marginTop: 12, fontSize: 16, color: '#6B7280', }, errorContainer: { padding: 24, alignItems: 'center', marginTop: 50, }, errorText: { fontSize: 16, color: '#EF4444', textAlign: 'center', marginBottom: 16, }, retryButton: { backgroundColor: '#3B82F6', paddingHorizontal: 24, paddingVertical: 12, borderRadius: 8, }, retryButtonText: { color: '#FFFFFF', fontSize: 16, fontWeight: '600', }, }); 🔍 } > {loading && !weather && ( )} {error && ( Chargement... )} {weather && ( <> {error} Réessayer > )}
Partage de code :
Rapidité de développement :
Coût réduit :
Performance légèrement inférieure :
Dépendance aux bibliothèques tierces :
Complexité pour fonctionnalités natives :
Les PWA sont des applications web qui offrent une expérience proche des applications natives grâce aux technologies web modernes :
Technologies clés :
Notes PWA
# Mes Notes
Mode hors ligne
Mise à jour disponible
// manifest.json
{
"name": "Notes PWA - Application de prise de notes",
"shortname": "Notes PWA",
"description": "Application de prise de notes moderne, rapide et utilisable hors ligne",
"starturl": "/",
"display": "standalone",
"backgroundcolor": "#FFFFFF",
"themecolor": "#3B82F6",
"orientation": "portrait-primary",
"categories": ["productivity", "utilities"],
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [
{
"src": "/screenshots/screenshot1.png",
"sizes": "540x720",
"type": "image/png"
}
],
"shortcuts": [
{
"name": "Nouvelle note",
"shortname": "Nouvelle",
"description": "Créer une nouvelle note rapidement",
"url": "/?action=new",
"icons": [
{
"src": "/icons/add-icon-96x96.png",
"sizes": "96x96"
}
]
}
]
}
// service-worker.js
const CACHEVERSION = 'v1.0.0';
const CACHENAME = notes-pwa-${CACHEVERSION};
const STATICASSETS = [
'/',
'/index.html',
'/css/styles.css',
'/js/app.js',
'/js/db.js',
'/icons/icon-192x192.png',
'/icons/icon-512x512.png',
];
// Installation du Service Worker
self.addEventListener('install', (event) => {
console.log('[SW] Installation...');
event.waitUntil(
caches.open(CACHENAME)
.then((cache) => {
console.log('[SW] Mise en cache des ressources statiques');
return cache.addAll(STATICASSETS);
})
.then(() => {
console.log('[SW] Installation terminée');
return self.skipWaiting();
})
);
});
// Activation du Service Worker
self.addEventListener('activate', (event) => {
console.log('[SW] Activation...');
event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHENAME)
.map((name) => {
console.log('[SW] Suppression ancien cache:', name);
return caches.delete(name);
})
);
})
.then(() => {
console.log('[SW] Activation terminée');
return self.clients.claim();
})
);
});
// Stratégie de cache : Network First avec fallback
self.addEventListener('fetch', (event) => {
const { request } = event;
// Ne pas mettre en cache les requêtes POST
if (request.method !== 'GET') {
return;
}
event.respondWith(
fetch(request)
.then((response) => {
// Cloner la réponse pour la mettre en cache
const responseClone = response.clone();
caches.open(CACHE_NAME)
.then((cache) => {
cache.put(request, responseClone);
});
return response;
})
.catch(() => {
// En cas d'échec réseau, utiliser le cache
return caches.match(request)
.then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
// Page offline de secours
if (request.destination === 'document') {
return caches.match('/offline.html');
}
return new Response('Ressource non disponible hors ligne', {
status: 503,
statusText: 'Service Unavailable',
});
});
})
);
});
// Synchronisation en arrière-plan
self.addEventListener('sync', (event) => {
console.log('[SW] Synchronisation en arrière-plan:', event.tag);
if (event.tag === 'sync-notes') {
event.waitUntil(syncNotes());
}
});
async function syncNotes() {
try {
// Récupérer les notes en attente de synchronisation
const db = await openDB();
const pendingNotes = await db.getAllFromIndex('notes', 'pending', 1);
for (const note of pendingNotes) {
try {
await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(note),
});
// Marquer comme synchronisé
note.pending = 0;
await db.put('notes', note);
} catch (error) {
console.error('[SW] Erreur sync note:', error);
}
}
} catch (error) {
console.error('[SW] Erreur synchronisation:', error);
}
}
// Notifications Push
self.addEventListener('push', (event) => {
console.log('[SW] Push reçu');
const data = event.data ? event.data.json() : {};
const options = {
body: data.body || 'Nouvelle notification',
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
vibrate: [200, 100, 200],
data: data.url || '/',
actions: [
{
action: 'open',
title: 'Ouvrir',
},
{
action: 'close',
title: 'Fermer',
},
],
};
event.waitUntil(
self.registration.showNotification(
data.title || 'Notes PWA',
options
)
);
});
// Gestion des clics sur notifications
self.addEventListener('notificationclick', (event) => {
console.log('[SW] Click notification:', event.action);
event.notification.close();
if (event.action === 'open') {
event.waitUntil(
clients.openWindow(event.notification.data)
);
}
});
// js/db.js - Gestion IndexedDB
class NotesDB {
constructor() {
this.dbName = 'NotesDB';
this.version = 1;
this.db = null;
}
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Créer le store si nécessaire
if (!db.objectStoreNames.contains('notes')) {
const store = db.createObjectStore('notes', {
keyPath: 'id',
autoIncrement: true,
});
store.createIndex('title', 'title', { unique: false });
store.createIndex('createdAt', 'createdAt', { unique: false });
store.createIndex('pending', 'pending', { unique: false });
}
};
});
}
async addNote(note) {
const transaction = this.db.transaction(['notes'], 'readwrite');
const store = transaction.objectStore('notes');
const noteData = {
...note,
createdAt: new Date().toISOString(),
pending: navigator.onLine ? 0 : 1,
};
return new Promise((resolve, reject) => {
const request = store.add(noteData);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async getAllNotes() {
const transaction = this.db.transaction(['notes'], 'readonly');
const store = transaction.objectStore('notes');
const index = store.index('createdAt');
return new Promise((resolve, reject) => {
const request = index.openCursor(null, 'prev');
const notes = [];
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
notes.push(cursor.value);
cursor.continue();
} else {
resolve(notes);
}
};
request.onerror = () => reject(request.error);
});
}
async deleteNote(id) {
const transaction = this.db.transaction(['notes'], 'readwrite');
const store = transaction.objectStore('notes');
return new Promise((resolve, reject) => {
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async updateNote(id, updates) {
const transaction = this.db.transaction(['notes'], 'readwrite');
const store = transaction.objectStore('notes');
return new Promise((resolve, reject) => {
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const note = getRequest.result;
if (!note) {
reject(new Error('Note non trouvée'));
return;
}
const updatedNote = { ...note, ...updates };
const putRequest = store.put(updatedNote);
putRequest.onsuccess = () => resolve(updatedNote);
putRequest.onerror = () => reject(putRequest.error);
};
getRequest.onerror = () => reject(getRequest.error);
});
}
async searchNotes(query) {
const allNotes = await this.getAllNotes();
const lowerQuery = query.toLowerCase();
return allNotes.filter(note =>
note.title.toLowerCase().includes(lowerQuery) ||
note.content.toLowerCase().includes(lowerQuery)
);
}
}
export default NotesDB;
// js/app.js - Application principale
import NotesDB from './db.js';
class NotesApp {
constructor() {
this.db = new NotesDB();
this.deferredPrompt = null;
this.initElements();
this.initEventListeners();
this.init();
}
initElements() {
this.noteTitleInput = document.getElementById('noteTitle');
this.noteContentInput = document.getElementById('noteContent');
this.addNoteBtn = document.getElementById('addNoteBtn');
this.notesContainer = document.getElementById('notesContainer');
this.installBtn = document.getElementById('installBtn');
this.offlineIndicator = document.getElementById('offlineIndicator');
this.updateNotification = document.getElementById('updateNotification');
this.updateBtn = document.getElementById('updateBtn');
}
initEventListeners() {
// Ajout de note
this.addNoteBtn.addEventListener('click', () => this.addNote());
this.noteContentInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
this.addNote();
}
});
// Installation PWA
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
this.deferredPrompt = e;
this.installBtn.hidden = false;
});
this.installBtn.addEventListener('click', async () => {
if (!this.deferredPrompt) return;
this.deferredPrompt.prompt();
const { outcome } = await this.deferredPrompt.userChoice;
console.log('Installation:', outcome);
this.deferredPrompt = null;
this.installBtn.hidden = true;
});
// Détection online/offline
window.addEventListener('online', () => this.updateOnlineStatus());
window.addEventListener('offline', () => this.updateOnlineStatus());
// Mise à jour du Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
this.updateNotification.hidden = false;
});
}
this.updateBtn.addEventListener('click', () => {
window.location.reload();
});
}
async init() {
try {
await this.db.init();
await this.loadNotes();
this.updateOnlineStatus();
// Enregistrer le Service Worker
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.register(
'/service-worker.js'
);
console.log('Service Worker enregistré:', registration);
// Vérifier les mises à jour
registration.addEventListener('updatefound', () => {
console.log('Mise à jour du Service Worker...');
});
}
} catch (error) {
console.error('Erreur initialisation:', error);
this.showError('Erreur de chargement de l'application');
}
}
async addNote() {
const title = this.noteTitleInput.value.trim();
const content = this.noteContentInput.value.trim();
if (!title || !content) {
this.showError('Veuillez remplir tous les champs');
return;
}
try {
const noteId = await this.db.addNote({ title, content });
this.noteTitleInput.value = '';
this.noteContentInput.value = '';
await this.loadNotes();
// Synchronisation en arrière-plan si hors ligne
if (!navigator.onLine && 'serviceWorker' in navigator && 'sync' in registration) {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-notes');
}
this.showSuccess('Note ajoutée avec succès');
} catch (error) {
console.error('Erreur ajout note:', error);
this.showError('Erreur lors de l'ajout de la note');
}
}
async loadNotes() {
try {
const notes = await this.db.getAllNotes();
this.renderNotes(notes);
} catch (error) {
console.error('Erreur chargement notes:', error);
this.showError('Erreur de chargement des notes');
}
}
renderNotes(notes) {
if (notes.length === 0) {
this.notesContainer.innerHTML = `
Aucune note pour le moment
Ajoutez votre première note ci-dessus
`;
return;
}
this.notesContainer.innerHTML = notes.map(note => `
${this.escapeHtml(note.title)}
${this.escapeHtml(note.content)}
`).join('');
// Gestionnaires de suppression
this.notesContainer.querySelectorAll('.btn-delete').forEach(btn => {
btn.addEventListener('click', (e) => {
const id = parseInt(e.target.dataset.id);
this.deleteNote(id);
});
});
}
async deleteNote(id) {
if (!confirm('Supprimer cette note ?')) return;
try {
await this.db.deleteNote(id);
await this.loadNotes();
this.showSuccess('Note supprimée');
} catch (error) {
console.error('Erreur suppression:', error);
this.showError('Erreur de suppression');
}
}
updateOnlineStatus() {
if (navigator.onLine) {
this.offlineIndicator.hidden = true;
} else {
this.offlineIndicator.hidden = false;
}
}
formatDate(dateString) {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'À l'instant';
if (minutes < 60) return Il y a ${minutes} min;
if (hours < 24) return Il y a ${hours} h;
if (days < 7) return Il y a ${days} j;
return date.toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
showSuccess(message) {
this.showToast(message, 'success');
}
showError(message) {
this.showToast(message, 'error');
}
showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = toast toast-${type};
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('show');
}, 10);
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
}
// Initialisation de l'application
document.addEventListener('DOMContentLoaded', () => {
new NotesApp();
});
/ css/styles.css /
:root {
--primary: #3B82F6;
--primary-dark: #2563EB;
--success: #10B981;
--error: #EF4444;
--text: #1F2937;
--text-muted: #6B7280;
--bg: #F3F4F6;
--surface: #FFFFFF;
--border: #E5E7EB;
--shadow: rgba(0, 0, 0, 0.1);
}
{
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, sans-serif;
background-color: var(--bg);
color: var(--text);
line-height: 1.6;
}
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-header {
background-color: var(--primary);
color: white;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px var(--shadow);
}
.app-header h1 {
font-size: 1.5rem;
font-weight: 600;
}
.install-btn {
background-color: rgba(255, 255, 255, 0.2);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
transition: background-color 0.2s;
}
.install-btn:hover {
background-color: rgba(255, 255, 255, 0.3);
}
.app-main {
flex: 1;
padding: 1rem;
max-width: 800px;
margin: 0 auto;
width: 100%;
}
.note-input-container {
background-color: var(--surface);
border-radius: 1rem;
padding: 1.5rem;
margin-bottom: 2rem;
box-shadow: 0 2px 8px var(--shadow);
}
.note-input,
.note-textarea {
width: 100%;
padding: 0.75rem;
margin-bottom: 1rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
font-size: 1rem;
font-family: inherit;
transition: border-color 0.2s;
}
.note-input:focus,
.note-textarea:focus {
outline: none;
border-color: var(--primary);
}
.note-textarea {
resize: vertical;
min-height: 100px;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background-color: var(--primary);
color: white;
width: 100%;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--primary-dark);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.notes-container {
display: grid;
gap: 1rem;
}
.note-card {
background-color: var(--surface);
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 2px 4px var(--shadow);
transition: transform 0.2s, box-shadow 0.2s;
}
.note-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px var(--shadow);
}
.note-card.pending {
border-left: 4px solid var(--primary);
}
.note-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 0.75rem;
}
.note-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text);
flex: 1;
}
.btn-delete {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
transition: all 0.2s;
}
.btn-delete:hover {
background-color: var(--error);
color: white;
}
.note-content {
color: var(--text);
margin-bottom: 1rem;
line-height: 1.6;
white-space: pre-wrap;
}
.note-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
.note-date {
font-size: 0.875rem;
color: var(--text-muted);
}
.badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
background-color: var(--primary);
color: white;
border-radius: 0.25rem;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--text-muted);
}
.empty-state p {
margin-bottom: 0.5rem;
}
.text-muted {
color: var(--text-muted);
font-size: 0.875rem;
}
.offline-indicator {
position: fixed;
bottom: 1rem;
right: 1rem;
background-color: var(--error);
color: white;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
box-shadow: 0 4px 8px var(--shadow);
font-size: 0.875rem;
z-index: 1000;
}
.update-notification {
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
background-color: var(--surface);
padding: 1rem 1.5rem;
border-radius: 0.5rem;
box-shadow: 0 4px 12px var(--shadow);
display: flex;
gap: 1rem;
align-items: center;
z-index: 1000;
}
.btn-secondary {
background-color: var(--success);
color: white;
padding: 0.5rem 1rem;
}
.btn-secondary:hover {
background-color: #059669;
}
.toast {
position: fixed;
top: 1rem;
right: 1rem;
padding: 1rem 1.5rem;
border-radius: 0.5rem;
color: white;
font-weight: 500;
opacity: 0;
transform: translateY(-1rem);
transition: all 0.3s;
z-index: 1000;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast-success {
background-color: var(--success);
}
.toast-error {
background-color: var(--error);
}
/ Responsive /
@media (max-width: 640px) {
.app-header h1 {
font-size: 1.25rem;
}
.install-btn {
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
}
.note-input-container {
padding: 1rem;
}
.note-card {
padding: 1rem;
}
}
/ Support mode sombre (optionnel) /
@media (prefers-color-scheme: dark) {
:root {
--text: #F9FAFB;
--text-muted: #9CA3AF;
--bg: #111827;
--surface: #1F2937;
--border: #374151;
--shadow: rgba(0, 0, 0, 0.3);
}
}
3.3 Avantages des PWA
Accessibilité universelle :
Accessible via URL (pas d’app store)
Fonctionne sur tous les OS
Mise à jour instantanée
Coût minimal :
Technologies web standard
Pas de frais d’app store
Une seule codebase
Fonctionnement hors ligne :
Service Workers
Cache intelligent
Synchronisation en arrière-plan
3.4 Inconvénients des PWA
Limitations iOS :
Push notifications limitées sur iOS
Pas d’accès à certaines API natives
Installation moins évidente
Performance inférieure :
Animations moins fluides que le natif
Consommation mémoire plus élevée
Pas d’accès GPU direct
Découvrabilité réduite :
Pas de présence dans les app stores (principalement)
Dépendance au SEO
Moins de visibilité
4. Tableau comparatif complet
Critère
Natif
Hybride
PWA
Performance
Excellente (100%)
Très bonne (85-95%)
Bonne (70-85%)
Coût développement
Élevé (2x équipes)
Moyen (1 équipe)
Faible (web)
Délai de mise sur marché
Long (2x dev)
Moyen
Court
Accès fonctionnalités natives
Complet et immédiat
Bon (via plugins)
Limité
Expérience utilisateur
Optimale
Très bonne
Bonne
Maintenance
Complexe (2 codebases)
Simplifiée
Simple
Hors ligne
Excellent
Excellent
Très bon
Distribution
App Stores
App Stores
Web + stores
Mises à jour
Validation stores
Validation stores
Instantanées
Taille application
Petite
Moyenne/Grande
Très petite
5. Critères de choix : Guide décisionnel
5.1 Choisir le NATIF si :
Performance critique :
– Jeux 3D, AR/VR
– Traitement vidéo/photo intensif
– Applications temps réel
Fonctionnalités système avancées :
– Intégration profonde OS (widgets, extensions)
– Utilisation intensive capteurs
– Accès bas niveau matériel
Budget confortable :
– Équipes iOS et Android disponibles
– Temps de développement non contraint
– Maintenance à long terme assurée
5.2 Choisir l’HYBRIDE (React Native/Flutter) si :
Équilibre performance/coût :
– Applications métier
– E-commerce
– Réseaux sociaux
Rapidité de mise sur le marché :
– MVP (Minimum Viable Product)
– Startup avec budget limité
– Itération rapide requise
Équipe web existante :
– Compétences JavaScript/TypeScript
– Réutilisation code web existant
– Courbe d’apprentissage réduite
5.3 Choisir PWA si :
Accessibilité maximale :
– Marchés émergents
– Connexion limitée
– Pas de dépendance aux app stores
Budget très limité :
– Prototype rapide
– Contenu informatif
– Application simple
Mise à jour fréquente :
– Contenu dynamique
– Corrections rapides
– A/B testing intensif
6. Cas d’usage réels par approche
Natif :
Instagram : Performance photo/vidéo critique
Uber : Géolocalisation temps réel, maps
Pokémon GO : AR intensive, capteurs
Hybride (React Native) :
Facebook : Partage de code, itération rapide
Airbnb : UI riche, performance acceptable
Bloomberg : Données temps réel, multi-plateforme
Hybride (Flutter) :
Google Ads : UI complexe, animations
Alibaba : E-commerce à grande échelle
BMW : Application connectée véhicule
PWA :
Twitter Lite : Marchés émergents, données limitées
Pinterest : Découverte web, installation progressive
Starbucks : Commande, paiement, offline
Conclusion
Le choix entre natif, hybride et PWA dépend de multiples facteurs : budget, délai, expertise, fonctionnalités requises et public cible. En 2025, les frameworks hybrides ont considérablement réduit l’écart de performance avec le natif, tandis que les PWA gagnent en maturité.
Recommandation générale :
Commencez par une PWA pour valider le concept
Évoluez vers l’hybride pour une application complète
Passez au natif seulement si les limitations hybrides deviennent bloquantes
L’approche progressive permet de minimiser les risques tout en optimisant l’investissement. De nombreuses applications à succès combinent même ces approches : PWA pour l’acquisition, application hybride pour l’engagement, et modules natifs pour les fonctionnalités critiques.
Call-to-Action : Évaluez votre projet selon les critères présentés et n’hésitez pas à combiner les approches selon les besoins spécifiques de chaque fonctionnalité. Le développement mobile moderne offre une flexibilité sans précédent pour créer des expériences utilisateur exceptionnelles.
Cet article est vivant — corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisissez celui qui vous convient.