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.
1. Développement Natif : Performance maximale
1.1 Définition et technologies
Le développement natif utilise les langages et outils officiels de chaque plateforme :
iOS :
- Swift (moderne, rapide, type-safe)
- UIKit ou SwiftUI pour l’interface
- Xcode comme IDE
- Kotlin (recommandé depuis 2019)
- Jetpack Compose pour l’interface moderne
- Android Studio comme IDE
Android :
1.2 Exemple concret : Liste de tâches native
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<AnyCancellable>()
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 = "saved_tasks"
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<Task, Error> {
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<AnyCancellable>())
}
.eraseToAnyPublisher()
}
func updateTask(* task: Task) -> AnyPublisher<Task, Error> {
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<AnyCancellable>())
}
.eraseToAnyPublisher()
}
func deleteTask(* task: Task) -> AnyPublisher<Void, Error> {
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<AnyCancellable>())
}
.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<List<Task>>
@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,
"task_database"
)
.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<List<Task>> = 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<Task> = 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<TaskUiState> = *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<Task>,
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))
}
1.3 Avantages du développement natif
Performance optimale :
Accès complet aux fonctionnalités :
Expérience utilisateur native :
1.4 Inconvénients du développement natif
Coût de développement élevé :
Temps de développement :
Complexité de maintenance :
2. Développement Hybride : Un code, deux plateformes
2.1 Technologies principales
React Native (JavaScript/TypeScript) :
Flutter (Dart) :
2.2 Exemple : Application météo avec React Native
// 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 API_KEY = 'your_api_key'; // À remplacer par votre clé
const BASE_URL = 'https://api.weatherapi.com/v1';
class WeatherService {
async getWeatherByCoords(
latitude: number,
longitude: number
): Promise<WeatherData> {
try {
const response = await axios.get(${BASE_URL}/forecast.json, {
params: {
key: API_KEY,
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<WeatherData> {
try {
const response = await axios.get(${BASE_URL}/forecast.json, {
params: {
key: API_KEY,
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.temp_c),
condition: data.current.condition.text,
humidity: data.current.humidity,
windSpeed: Math.round(data.current.wind_kph),
icon: data.current.condition.icon,
forecast: data.forecast.forecastday.map((day: any) => ({
date: day.date,
maxTemp: Math.round(day.day.maxtemp_c),
minTemp: Math.round(day.day.mintemp_c),
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<WeatherData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const requestLocationPermission = async (): Promise<boolean> => {
if (Platform.OS === 'ios') {
return true; // Géré par Info.plist
}
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
{
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<LocationCoordinates> => {
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<WeatherCardProps> = ({ weather }) => {
return (
<View style={styles.card}>
<Text style={styles.location}>{weather.location}</Text>
<View style={styles.mainInfo}>
<Image
source={{ uri: https:${weather.icon} }}
style={styles.icon}
resizeMode="contain"
/>
<Text style={styles.temperature}>{weather.temperature}°C</Text>
</View>
<Text style={styles.condition}>{weather.condition}</Text>
<View style={styles.details}>
<View style={styles.detailItem}>
<Text style={styles.detailLabel}>Humidité</Text>
<Text style={styles.detailValue}>{weather.humidity}%</Text>
</View>
<View style={styles.detailItem}>
<Text style={styles.detailLabel}>Vent</Text>
<Text style={styles.detailValue}>{weather.windSpeed} km/h</Text>
</View>
</View>
</View>
);
};
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<ForecastListProps> = ({ forecast }) => {
const renderItem = ({ item }: { item: ForecastDay }) => {
const date = new Date(item.date);
const dayName = date.toLocaleDateString('fr-BE', { weekday: 'short' });
return (
<View style={styles.forecastItem}>
<Text style={styles.dayName}>{dayName}</Text>
<Image
source={{ uri: https:${item.icon} }}
style={styles.forecastIcon}
/>
<View style={styles.tempRange}>
<Text style={styles.maxTemp}>{item.maxTemp}°</Text>
<Text style={styles.minTemp}>{item.minTemp}°</Text>
</View>
</View>
);
};
return (
<View style={styles.container}>
<Text style={styles.title}>Prévisions 7 jours</Text>
<FlatList
data={forecast}
renderItem={renderItem}
keyExtractor={(item) => item.date}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.list}
/>
</View>
);
};
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 (
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.flex}
>
<View style={styles.searchContainer}>
<TextInput
style={styles.searchInput}
placeholder="Rechercher une ville..."
value={searchCity}
onChangeText={setSearchCity}
onSubmitEditing={handleSearch}
returnKeyType="search"
/>
<TouchableOpacity
style={styles.searchButton}
onPress={handleSearch}
disabled={loading}
>
<Text style={styles.searchButtonText}>🔍</Text>
</TouchableOpacity>
</View>
<ScrollView
style={styles.content}
contentContainerStyle={styles.contentContainer}
refreshControl={
<RefreshControl
refreshing={loading}
onRefresh={refresh}
tintColor="#3B82F6"
/>
}
>
{loading && !weather && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#3B82F6" />
<Text style={styles.loadingText}>Chargement...</Text>
</View>
)}
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={fetchWeatherByLocation}
>
<Text style={styles.retryButtonText}>Réessayer</Text>
</TouchableOpacity>
</View>
)}
{weather && (
<>
<WeatherCard weather={weather} />
<ForecastList forecast={weather.forecast} />
</>
)}
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
};
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',
},
});
2.3 Avantages du développement hybride
Partage de code :
Rapidité de développement :
Coût réduit :
2.4 Inconvénients du développement hybride
Performance légèrement inférieure :
Dépendance aux bibliothèques tierces :
Complexité pour fonctionnalités natives :
3. Progressive Web Apps (PWA) : Le web devient mobile
3.1 Définition et technologies
Les PWA sont des applications web qui offrent une expérience proche des applications natives grâce aux technologies web modernes :
Technologies clés :
3.2 Exemple complet : PWA de notes
<!-- index.html -->
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#3B82F6">
<meta name="description" content="Application de prise de notes moderne et rapide">
<title>Notes PWA</title>
<!-- Manifest -->
<link rel="manifest" href="/manifest.json">
<!-- Icons -->
<link rel="apple-touch-icon" href="/icons/icon-192x192.png">
<link rel="icon" type="image/png" href="/icons/icon-32x32.png">
<!-- Styles -->
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<div id="app">
<header class="app-header">
# Mes Notes
<button id="installBtn" class="install-btn" hidden>
Installer l'application
</button>
</header>
<main class="app-main">
<div class="note-input-container">
<input
type="text"
id="noteTitle"
placeholder="Titre de la note"
class="note-input"
>
<textarea
id="noteContent"
placeholder="Contenu de la note"
class="note-textarea"
rows="4"
></textarea>
<button id="addNoteBtn" class="btn btn-primary">
Ajouter une note
</button>
</div>
<div id="notesContainer" class="notes-container"></div>
</main>
<div id="offlineIndicator" class="offline-indicator" hidden>
Mode hors ligne
</div>
<div id="updateNotification" class="update-notification" hidden>
<p>Mise à jour disponible</p>
<button id="updateBtn" class="btn btn-secondary">Actualiser</button>
</div>
</div>
<script src="/js/app.js" type="module"></script>
</body>
</html>
// manifest.json
{
"name": "Notes PWA - Application de prise de notes",
"short_name": "Notes PWA",
"description": "Application de prise de notes moderne, rapide et utilisable hors ligne",
"start_url": "/",
"display": "standalone",
"background_color": "#FFFFFF",
"theme_color": "#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",
"short_name": "Nouvelle",
"description": "Créer une nouvelle note rapidement",
"url": "/?action=new",
"icons": [
{
"src": "/icons/add-icon-96x96.png",
"sizes": "96x96"
}
]
}
]
}
// service-worker.js
const CACHE_VERSION = 'v1.0.0';
const CACHE_NAME = notes-pwa-${CACHE_VERSION};
const STATIC_ASSETS = [
'/',
'/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(CACHE_NAME)
.then((cache) => {
console.log('[SW] Mise en cache des ressources statiques');
return cache.addAll(STATIC_ASSETS);
})
.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 !== CACHE_NAME)
.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 = `
<div class="empty-state">
<p>Aucune note pour le moment</p>
<p class="text-muted">Ajoutez votre première note ci-dessus</p>
</div>
`;
return;
}
this.notesContainer.innerHTML = notes.map(note => `
<div class="note-card ${note.pending ? 'pending' : ''}" data-id="${note.id}">
<div class="note-header">
<h3 class="note-title">${this.escapeHtml(note.title)}</h3>
<button class="btn-delete" data-id="${note.id}">×</button>
</div>
<p class="note-content">${this.escapeHtml(note.content)}</p>
<div class="note-footer">
<span class="note-date">${this.formatDate(note.createdAt)}</span>
${note.pending ? '<span class="badge">En attente de sync</span>' : ''}
</div>
</div>
`).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-BE', {
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 :
Coût minimal :
Fonctionnement hors ligne :
3.4 Inconvénients des PWA
Limitations iOS :
Performance inférieure :
Découvrabilité réduite :
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 :
– Jeux 3D, AR/VR
– Traitement vidéo/photo intensif
– Applications temps réel
– Intégration profonde OS (widgets, extensions)
– Utilisation intensive capteurs
– Accès bas niveau matériel
– É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 :
– Applications métier
– E-commerce
– Réseaux sociaux
– MVP (Minimum Viable Product)
– Startup avec budget limité
– Itération rapide requise
– Compétences JavaScript/TypeScript
– Réutilisation code web existant
– Courbe d’apprentissage réduite
5.3 Choisir PWA si :
– Marchés émergents
– Connexion limitée
– Pas de dépendance aux app stores
– Prototype rapide
– Contenu informatif
– Application simple
– Contenu dynamique
– Corrections rapides
– A/B testing intensif
6. Cas d’usage réels par approche
Natif :
Hybride (React Native) :
Hybride (Flutter) :
PWA :
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 :
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.