Desarrollo de Aplicaciones Móviles: React Native, Flutter, Swift, Kotlin y PWA Cross-Platform
Este artículo es una introducción completa al desarrollo de aplicaciones móviles, incluyendo React Native, Flutter, Swift, Kotlin y PWA con estrategias cross-platform y ejemplos prácticos.
En síntesis
El desarrollo de aplicaciones móviles abarca tres enfoques: nativas (iOS/Android), cross-platform (React Native, Flutter) y aplicaciones web (PWA). Las nativas ofrecen el mejor rendimiento, las cross-platform aceleran el desarrollo, y las PWA simplifican la distribución.
Descripción técnica compacta
El desarrollo de aplicaciones móviles es la creación de aplicaciones para dispositivos móviles como smartphones y tablets usando diferentes enfoques tecnológicos.
Enfoques de desarrollo:
Desarrollo Nativo
- iOS: Swift con UIKit o SwiftUI
- Android: Kotlin con Jetpack Compose
- Ventajas: Mejor rendimiento, acceso completo a APIs
- Desventajas: Desarrollo específico por plataforma
Desarrollo Cross-Platform
- React Native: JavaScript/React para iOS y Android
- Flutter: Dart de Google para iOS y Android
- Ventajas: Reutilización de código, desarrollo más rápido
- Desventajas: Sobrecarga de rendimiento, acceso limitado a APIs
Progressive Web Apps (PWA)
- Tecnología: HTML5, CSS3, JavaScript con Service Workers
- Ventajas: Una única codebase, compatible con app stores, funcionalidad offline
- Desventajas: Acceso limitado a hardware, dependencia del navegador
Puntos clave para el examen
- Desarrollo de aplicaciones móviles: Creación de aplicaciones para dispositivos móviles
- Nativo: Desarrollo específico de plataforma (Swift/Kotlin)
- Cross-platform: Una codebase para múltiples plataformas
- React Native: Solución cross-platform basada en JavaScript
- Flutter: Solución cross-platform basada en Dart de Google
- PWA: Progressive Web Apps con comportamiento similar al de una app
- UI/UX: Diseño de interfaz y experiencia de usuario
- Relevante para IHK: Desarrollo moderno de aplicaciones móviles
Componentes principales
- Selección de plataforma: Nativo vs. Cross-platform vs. PWA
- Herramientas de desarrollo: IDEs, SDKs, frameworks
- Componentes UI: Controls nativos vs. componentes personalizados
- Gestión de estado: Flujo de datos y administración de estado
- Navegación: Navegación entre pantallas y routing
- Rendimiento: Optimización y gestión de memoria
- Testing: Tests unitarios, de integración y de UI
- Deployment: App Store y distribución
Ejemplos prácticos
1. Aplicación React Native con TypeScript
// App.tsx - Aplicación principal
import React from 'react';
import {
SafeAreaView,
StyleSheet,
ScrollView,
View,
Text,
StatusBar,
TouchableOpacity,
Alert,
ActivityIndicator
} from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { Provider, useSelector, useDispatch } from 'react-redux';
import { store } from './store';
import { fetchUsers, createUser, updateUser, deleteUser } from './store/userSlice';
// Definir tipos
interface User {
id: string;
name: string;
email: string;
phone: string;
address: string;
createdAt: string;
updatedAt: string;
}
interface RootState {
users: {
data: User[];
loading: boolean;
error: string | null;
};
}
// Componente Lista de Usuarios
const UserListScreen: React.FC = ({ navigation }) => {
const dispatch = useDispatch();
const { data: users, loading, error } = useSelector((state: RootState) => state.users);
React.useEffect(() => {
dispatch(fetchUsers());
}, [dispatch]);
const handleAddUser = () => {
navigation.navigate('UserForm', { mode: 'create' });
};
const handleEditUser = (user: User) => {
navigation.navigate('UserForm', { mode: 'edit', user });
};
const handleDeleteUser = (user: User) => {
Alert.alert(
'Eliminar usuario',
`¿Deseas eliminar a ${user.name}?`,
[
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Eliminar',
style: 'destructive',
onPress: () => dispatch(deleteUser(user.id)),
},
]
);
};
if (loading) {
return (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color="#2E86AB" />
<Text style={styles.loadingText}>Cargando usuarios...</Text>
</View>
);
}
if (error) {
return (
<View style={styles.centerContainer}>
<Text style={styles.errorText}>Error: {error}</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => dispatch(fetchUsers())}
>
<Text style={styles.retryButtonText}>Reintentar</Text>
</TouchableOpacity>
</View>
);
}
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="#ffffff" />
<View style={styles.header}>
<Text style={styles.headerTitle}>Lista de usuarios</Text>
<TouchableOpacity style={styles.addButton} onPress={handleAddUser}>
<Text style={styles.addButtonText}>+</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.scrollView}>
{users.map((user) => (
<View key={user.id} style={styles.userCard}>
<View style={styles.userInfo}>
<Text style={styles.userName}>{user.name}</Text>
<Text style={styles.userEmail}>{user.email}</Text>
<Text style={styles.userPhone}>{user.phone}</Text>
<Text style={styles.userAddress}>{user.address}</Text>
</View>
<View style={styles.userActions}>
<TouchableOpacity
style={[styles.actionButton, styles.editButton]}
onPress={() => handleEditUser(user)}
>
<Text style={styles.actionButtonText}>Editar</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.deleteButton]}
onPress={() => handleDeleteUser(user)}
>
<Text style={styles.actionButtonText}>Eliminar</Text>
</TouchableOpacity>
</View>
</View>
))}
</ScrollView>
</SafeAreaView>
);
};
// Componente Formulario de Usuario
const UserFormScreen: React.FC = ({ route, navigation }) => {
const dispatch = useDispatch();
const { mode, user } = route.params as { mode: 'create' | 'edit'; user?: User };
const [formData, setFormData] = React.useState<Partial<User>>(
user || {
name: '',
email: '',
phone: '',
address: '',
}
);
const [errors, setErrors] = React.useState<Record<string, string>>({});
const [loading, setLoading] = React.useState(false);
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name?.trim()) {
newErrors.name = 'El nombre es obligatorio';
}
if (!formData.email?.trim()) {
newErrors.email = 'El correo electrónico es obligatorio';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = 'Correo electrónico inválido';
}
if (!formData.phone?.trim()) {
newErrors.phone = 'El teléfono es obligatorio';
}
if (!formData.address?.trim()) {
newErrors.address = 'La dirección es obligatoria';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validateForm()) {
return;
}
setLoading(true);
try {
if (mode === 'create') {
await dispatch(createUser(formData as Omit<User, 'id' | 'createdAt' | 'updatedAt'>));
Alert.alert('Éxito', 'El usuario ha sido creado');
} else {
await dispatch(updateUser({ ...formData, id: user!.id } as User));
Alert.alert('Éxito', 'El usuario ha sido actualizado');
}
navigation.goBack();
} catch (error) {
Alert.alert('Error', 'Ha ocurrido un error');
} finally {
setLoading(false);
}
};
const updateField = (field: keyof User, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="#ffffff" />
<View style={styles.formHeader}>
<TouchableOpacity onPress={() => navigation.goBack()}>
<Text style={styles.cancelButton}>Cancelar</Text>
</TouchableOpacity>
<Text style={styles.formTitle}>
{mode === 'create' ? 'Crear usuario' : 'Editar usuario'}
</Text>
<TouchableOpacity onPress={handleSubmit} disabled={loading}>
<Text style={[styles.saveButton, loading && styles.disabledButton]}>
{loading ? 'Guardando...' : 'Guardar'}
</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.formScrollView}>
<View style={styles.form}>
<View style={styles.formGroup}>
<Text style={styles.label}>Nombre *</Text>
<TextInput
style={[styles.input, errors.name && styles.inputError]}
value={formData.name}
onChangeText={(value) => updateField('name', value)}
placeholder="Ingresa el nombre"
editable={!loading}
/>
{errors.name && <Text style={styles.errorText}>{errors.name}</Text>}
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Correo electrónico *</Text>
<TextInput
style={[styles.input, errors.email && styles.inputError]}
value={formData.email}
onChangeText={(value) => updateField('email', value)}
placeholder="Ingresa el correo electrónico"
keyboardType="email-address"
autoCapitalize="none"
editable={!loading}
/>
{errors.email && <Text style={styles.errorText}>{errors.email}</Text>}
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Teléfono *</Text>
<TextInput
style={[styles.input, errors.phone && styles.inputError]}
value={formData.phone}
onChangeText={(value) => updateField('phone', value)}
placeholder="Ingresa el teléfono"
keyboardType="phone-pad"
editable={!loading}
/>
{errors.phone && <Text style={styles.errorText}>{errors.phone}</Text>}
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Dirección *</Text>
<TextInput
style={[styles.input, errors.address && styles.inputError]}
value={formData.address}
onChangeText={(value) => updateField('address', value)}
placeholder="Ingresa la dirección"
multiline
numberOfLines={3}
editable={!loading}
/>
{errors.address && <Text style={styles.errorText}>{errors.address}</Text>}
</View>
</View>
</ScrollView>
</SafeAreaView>
);
};
// Stack de Navegación
const Stack = createStackNavigator();
const App: React.FC = () => {
return (
<Provider store={store}>
<NavigationContainer>
<Stack.Navigator initialRouteName="UserList">
<Stack.Screen
name="UserList"
component={UserListScreen}
options={{
title: 'Aplicación de usuarios',
headerStyle: {
backgroundColor: '#2E86AB',
},
headerTintColor: '#ffffff',
}}
/>
<Stack.Screen
name="UserForm"
component={UserFormScreen}
options={{
title: 'Formulario de usuario',
headerStyle: {
backgroundColor: '#2E86AB',
},
headerTintColor: '#ffffff',
}}
/>
</Stack.Navigator>
</NavigationContainer>
</Provider>
);
};
// Estilos
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#ffffff',
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
headerTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#333333',
},
addButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#2E86AB',
justifyContent: 'center',
alignItems: 'center',
},
addButtonText: {
color: '#ffffff',
fontSize: 24,
fontWeight: 'bold',
},
scrollView: {
flex: 1,
padding: 16,
},
userCard: {
backgroundColor: '#ffffff',
borderRadius: 8,
padding: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
userInfo: {
marginBottom: 12,
},
userName: {
fontSize: 18,
fontWeight: 'bold',
color: '#333333',
marginBottom: 4,
},
userEmail: {
fontSize: 14,
color: '#666666',
marginBottom: 2,
},
userPhone: {
fontSize: 14,
color: '#666666',
marginBottom: 2,
},
userAddress: {
fontSize: 14,
color: '#666666',
},
userActions: {
flexDirection: 'row',
justifyContent: 'space-between',
},
actionButton: {
flex: 1,
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 4,
marginHorizontal: 4,
},
editButton: {
backgroundColor: '#2E86AB',
},
deleteButton: {
backgroundColor: '#C73E1D',
},
actionButtonText: {
color: '#ffffff',
textAlign: 'center',
fontSize: 14,
fontWeight: 'bold',
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: '#666666',
},
errorText: {
marginTop: 16,
fontSize: 16,
color: '#C73E1D',
textAlign: 'center',
},
retryButton: {
marginTop: 16,
paddingVertical: 12,
paddingHorizontal: 24,
backgroundColor: '#2E86AB',
borderRadius: 8,
},
retryButtonText: {
color: '#ffffff',
fontSize: 16,
fontWeight: 'bold',
},
formHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
cancelButton: {
fontSize: 16,
color: '#666666',
},
formTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#333333',
},
saveButton: {
fontSize: 16,
color: '#2E86AB',
fontWeight: 'bold',
},
disabledButton: {
color: '#cccccc',
},
formScrollView: {
flex: 1,
},
form: {
padding: 16,
},
formGroup: {
marginBottom: 20,
},
label: {
fontSize: 16,
fontWeight: 'bold',
color: '#333333',
marginBottom: 8,
},
input: {
borderWidth: 1,
borderColor: '#e0e0e0',
borderRadius: 8,
padding: 12,
fontSize: 16,
color: '#333333',
},
inputError: {
borderColor: '#C73E1D',
},
errorText: {
marginTop: 4,
fontSize: 14,
color: '#C73E1D',
},
});
export default App;
2. Aplicación Flutter con Dart
import 'package:flutter/material.dart';
import 'package:flutter_redux/flutter_redux.dart';
import 'package:redux/redux.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'dart:async';
// Redux State
class AppState {
final List<User> users;
final bool isLoading;
final String? error;
AppState({
required this.users,
required this.isLoading,
this.error,
});
AppState copyWith({
List<User>? users,
bool? isLoading,
String? error,
}) {
return AppState(
users: users ?? this.users,
isLoading: isLoading ?? this.isLoading,
error: error ?? this.error,
);
}
}
// User Model
class User {
final String id;
final String name;
final String email;
final String phone;
final String address;
final DateTime createdAt;
final DateTime updatedAt;
User({
required this.id,
required this.name,
required this.email,
required this.phone,
required this.address,
required this.createdAt,
required this.updatedAt,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
name: json['name'],
email: json['email'],
phone: json['phone'],
address: json['address'],
createdAt: DateTime.parse(json['createdAt']),
updatedAt: DateTime.parse(json['updatedAt']),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
'phone': phone,
'address': address,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
}
User copyWith({
String? name,
String? email,
String? phone,
String? address,
}) {
return User(
id: id,
name: name ?? this.name,
email: email ?? this.email,
phone: phone ?? this.phone,
address: address ?? this.address,
createdAt: createdAt,
updatedAt: DateTime.now(),
);
}
}
// Redux Actions
enum UserAction { fetch, create, update, delete }
class FetchUsersAction {}
class CreateUsersAction {
final User user;
CreateUsersAction(this.user);
}
class UpdateUsersAction {
final User user;
UpdateUsersAction(this.user);
}
class DeleteUsersAction {
final String userId;
DeleteUsersAction(this.userId);
}
class SetLoadingAction {
final bool isLoading;
SetLoadingAction(this.isLoading);
}
class SetErrorAction {
final String error;
SetErrorAction(this.error);
}
// API Service
class ApiService {
static const String baseUrl = 'https://api.example.com/users';
static Future<List<User>> getUsers() async {
try {
final response = await http.get(Uri.parse(baseUrl));
if (response.statusCode == 200) {
List<dynamic> data = json.decode(response.body);
return data.map((json) => User.fromJson(json)).toList();
} else {
throw Exception('Failed to load users');
}
} catch (e) {
throw Exception('Network error: $e');
}
}
static Future<User> createUser(User user) async {
try {
final response = await http.post(
Uri.parse(baseUrl),
headers: {'Content-Type': 'application/json'},
body: json.encode(user.toJson()),
);
if (response.statusCode == 201) {
return User.fromJson(json.decode(response.body));
} else {
throw Exception('Failed to create user');
}
} catch (e) {
throw Exception('Network error: $e');
}
}
static Future<User> updateUser(User user) async {
try {
final response = await http.put(
Uri.parse('$baseUrl/${user.id}'),
headers: {'Content-Type': 'application/json'},
body: json.encode(user.toJson()),
);
if (response.statusCode == 200) {
return User.fromJson(json.decode(response.body));
} else {
throw Exception('Failed to update user');
}
} catch (e) {
throw Exception('Network error: $e');
}
}
static Future<void> deleteUser(String userId) async {
try {
final response = await http.delete(Uri.parse('$baseUrl/$userId'));
if (response.statusCode != 204) {
throw Exception('Failed to delete user');
}
} catch (e) {
throw Exception('Network error: $e');
}
}
}
// Redux Reducer
AppState appReducer(AppState state, dynamic action) {
switch (action.runtimeType) {
case SetLoadingAction:
return state.copyWith(isLoading: (action as SetLoadingAction).isLoading);
case SetErrorAction:
return state.copyWith(error: (action as SetErrorAction).error, isLoading: false);
case FetchUsersAction:
return state.copyWith(isLoading: true, error: null);
case CreateUsersAction:
return state.copyWith(isLoading: true, error: null);
case UpdateUsersAction:
return state.copyWith(isLoading: true, error: null);
case DeleteUsersAction:
return state.copyWith(isLoading: true, error: null);
default:
return state;
}
}
// Middleware for async actions
class UsersMiddleware extends MiddlewareClass<AppState> {
@override
AppState call(Store<AppState> store, dynamic action, NextDispatcher next) {
if (action is FetchUsersAction) {
_fetchUsers(store);
} else if (action is CreateUsersAction) {
_createUser(store, action.user);
} else if (action is UpdateUsersAction) {
_updateUser(store, action.user);
} else if (action is DeleteUsersAction) {
_deleteUser(store, action.userId);
}
return next(action);
}
Future<void> _fetchUsers(Store<AppState> store) async {
try {
store.dispatch(SetLoadingAction(true));
final users = await ApiService.getUsers();
store.dispatch(SetUsersAction(users));
store.dispatch(SetLoadingAction(false));
} catch (e) {
store.dispatch(SetErrorAction('Failed to fetch users: $e'));
}
}
Future<void> _createUser(Store<AppState> store, User user) async {
try {
store.dispatch(SetLoadingAction(true));
final createdUser = await ApiService.createUser(user);
final users = List<User>.from(store.state.users)..add(createdUser);
store.dispatch(SetUsersAction(users));
store.dispatch(SetLoadingAction(false));
} catch (e) {
store.dispatch(SetErrorAction('Failed to create user: $e'));
}
}
Future<void> _updateUser(Store<AppState> store, User user) async {
try {
store.dispatch(SetLoadingAction(true));
final updatedUser = await ApiService.updateUser(user);
final users = store.state.users.map((u) => u.id == user.id ? updatedUser : u).toList();
store.dispatch(SetUsersAction(users));
store.dispatch(SetLoadingAction(false));
} catch (e) {
store.dispatch(SetErrorAction('Failed to update user: $e'));
}
}
Future<void> _deleteUser(Store<AppState> store, String userId) async {
try {
store.dispatch(SetLoadingAction(true));
await ApiService.deleteUser(userId);
final users = store.state.users.where((u) => u.id != userId).toList();
store.dispatch(SetUsersAction(users));
store.dispatch(SetLoadingAction(false));
} catch (e) {
store.dispatch(SetErrorAction('Failed to delete user: $e'));
}
}
}
class SetUsersAction {
final List<User> users;
SetUsersAction(this.users);
}
// Main App
void main() {
final store = Store<AppState>(
appReducer,
initialState: AppState(users: [], isLoading: false),
middleware: [UsersMiddleware()],
);
runApp(MyApp(store: store));
}
class MyApp extends StatelessWidget {
final Store<AppState> store;
const MyApp({Key? key, required this.store}) : super(key: key);
@override
Widget build(BuildContext context) {
return StoreProvider<AppState>(
store: store,
child: MaterialApp(
title: 'Flutter User App',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: UserListScreen(),
),
);
}
}
// User List Screen
class UserListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Lista de usuarios'),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
elevation: 2,
),
body: StoreConnector<AppState, _ViewModel>(
converter: (store) => _ViewModel(
users: store.state.users,
isLoading: store.state.isLoading,
error: store.state.error,
),
builder: (context, viewModel) {
if (viewModel.isLoading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Cargando usuarios...'),
],
),
);
}
if (viewModel.error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, size: 64, color: Colors.red),
SizedBox(height: 16),
Text(
'Error: ${viewModel.error}',
style: TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
SizedBox(height: 16),
ElevatedButton(
onPressed: () => StoreProvider.of<AppState>(context).dispatch(FetchUsersAction()),
child: Text('Reintentar'),
),
],
),
);
}
if (viewModel.users.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.people_outline, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text(
'No se encontraron usuarios',
style: TextStyle(color: Colors.grey),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
StoreProvider.of<AppState>(context).dispatch(FetchUsersAction());
},
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: viewModel.users.length,
itemBuilder: (context, index) {
final user = viewModel.users[index];
return UserCard(user: user);
},
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => UserFormScreen()),
);
},
backgroundColor: Colors.blue,
child: const Icon(Icons.add),
),
);
}
}
// ViewModel
class _ViewModel {
final List<User> users;
final bool isLoading;
final String? error;
_ViewModel({
required this.users,
required this.isLoading,
this.error,
});
}
// User Card Widget
class UserCard extends StatelessWidget {
final User user;
const UserCard({Key? key, required this.user}) : super(key: key);
@override
Widget build(BuildContext context) {
return Card(
elevation: 4,
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundColor: Colors.blue,
child: Text(
user.name.isNotEmpty ? user.name[0].toUpperCase() : 'U',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.name,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
user.email,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit),
SizedBox(width: 8),
Text('Editar'),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, color: Colors.red),
SizedBox(width: 8),
Text('Eliminar', style: TextStyle(color: Colors.red)),
],
),
),
],
onSelected: (value) {
if (value == 'edit') {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UserFormScreen(user: user),
),
);
} else if (value == 'delete') {
_showDeleteDialog(context);
}
},
),
],
),
const SizedBox(height: 12),
_buildInfoRow(Icons.phone, user.phone),
_buildInfoRow(Icons.location_on, user.address),
const SizedBox(height: 8),
Text(
'Creado: ${_formatDate(user.createdAt)}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
],
),
),
);
}
Widget _buildInfoRow(IconData icon, String text) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(icon, size: 16, color: Colors.grey[600]),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
),
],
),
);
}
String _formatDate(DateTime date) {
return '${date.day}.${date.month}.${date.year}';
}
void _showDeleteDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Eliminar usuario'),
content: Text('¿Deseas eliminar a ${user.name}?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancelar'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
StoreProvider.of<AppState>(context).dispatch(DeleteUsersAction(user.id));
},
child: Text(
'Eliminar',
style: TextStyle(color: Colors.red),
),
),
],
),
);
}
}
// User Form Screen
class UserFormScreen extends StatefulWidget {
final User? user;
const UserFormScreen({Key? key, this.user}) : super(key: key);
@override
_UserFormScreenState createState() => _UserFormScreenState();
}
class _UserFormScreenState extends State<UserFormScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _phoneController = TextEditingController();
final _addressController = TextEditingController();
bool _isLoading = false;
String? _errorMessage;
@override
void initState() {
super.initState();
if (widget.user != null) {
_nameController.text = widget.user!.name;
_emailController.text = widget.user!.email;
_phoneController.text = widget.user!.phone;
_addressController.text = widget.user!.address;
}
}
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_phoneController.dispose();
_addressController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.user == null ? 'Crear usuario' : 'Editar usuario'),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
actions: [
if (!_isLoading)
TextButton(
onPressed: _saveUser,
child: Text(
'Guardar',
style: TextStyle(color: Colors.white),
),
)
else
Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_errorMessage != null)
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red[50],
border: Border.all(color: Colors.red[200]!),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.error, color: Colors.red[700]),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: TextStyle(color: Colors.red[700]),
),
),
],
),
),
_buildTextField(
controller: _nameController,
label: 'Nombre',
icon: Icons.person,
validator: (value) {
if (value == null || value.isEmpty) {
return 'El nombre es requerido';
}
return null;
},
),
const SizedBox(height: 16),
_buildTextField(
controller: _emailController,
label: 'Correo electrónico',
icon: Icons.email,
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return 'El correo electrónico es requerido';
}
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) {
return 'Correo electrónico inválido';
}
return null;
},
),
const SizedBox(height: 16),
_buildTextField(
controller: _phoneController,
label: 'Teléfono',
icon: Icons.phone,
keyboardType: TextInputType.phone,
validator: (value) {
if (value == null || value.isEmpty) {
return 'El teléfono es requerido';
}
return null;
},
),
const SizedBox(height: 16),
_buildTextField(
controller: _addressController,
label: 'Dirección',
icon: Icons.location_on,
maxLines: 3,
validator: (value) {
if (value == null || value.isEmpty) {
return 'La dirección es requerida';
}
return null;
},
),
],
),
),
),
);
}
Widget _buildTextField({
required TextEditingController controller,
required String label,
required IconData icon,
TextInputType? keyboardType,
int? maxLines,
String? Function(String?)? validator,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
TextFormField(
controller: controller,
keyboardType: keyboardType,
maxLines: maxLines,
validator: validator,
decoration: InputDecoration(
prefixIcon: Icon(icon),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.blue),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.red),
),
),
),
],
);
}
Future<void> _saveUser() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final user = User(
id: widget.user?.id ?? DateTime.now().millisecondsSinceEpoch.toString(),
name: _nameController.text,
email: _emailController.text,
phone: _phoneController.text,
address: _addressController.text,
createdAt: widget.user?.createdAt ?? DateTime.now(),
updatedAt: DateTime.now(),
);
final store = StoreProvider.of<AppState>(context);
if (widget.user == null) {
store.dispatch(CreateUsersAction(user));
} else {
store.dispatch(UpdateUsersAction(user));
}
Navigator.pop(context);
} catch (e) {
setState(() {
_errorMessage = 'Ha ocurrido un error: $e';
});
} finally {
setState(() {
_isLoading = false;
});
}
}
}
3. Progressive Web App (PWA)
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aplicación PWA Móvil</title>
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#2E86AB">
<meta name="description" content="Progressive Web App para usuarios móviles">
<!-- Apple Web App Meta Tags -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="PWA Móvil">
<link rel="apple-touch-icon" href="icons/icon-192x192.png">
<!-- Manifest -->
<link rel="manifest" href="manifest.json">
<!-- Service Worker Registration -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('SW registered: ', registration);
})
.catch(registrationError => {
console.log('SW registration failed: ', registrationError);
});
});
}
</script>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Custom Styles -->
<style>
:root {
--primary-color: #2E86AB;
--secondary-color: #A23B72;
--accent-color: #F18F01;
--success-color: #C73E1D;
--warning-color: #F4A261;
--text-color: #333333;
--background-color: #f8f9fa;
--card-background: #ffffff;
--border-color: #e0e0e0;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--background-color);
color: var(--text-color);
line-height: 1.6;
}
.container {
max-width: 100%;
padding: 0 16px;
margin: 0 auto;
}
@media (min-width: 640px) {
.container {
max-width: 640px;
}
}
@media (min-width: 768px) {
.container {
max-width: 768px;
}
}
@media (min-width: 1024px) {
.container {
max-width: 1024px;
}
}
.card {
background: var(--card-background);
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 16px;
margin-bottom: 16px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-primary:hover {
background: #2563eb;
transform: translateY(-1px);
}
.btn-secondary {
background: var(--secondary-color);
color: white;
}
.btn-secondary:hover {
background: #8b2b5e;
transform: translateY(-1px);
}
.btn-outline {
background: transparent;
border: 2px solid var(--primary-color);
color: var(--primary-color);
}
.btn-outline:hover {
background: var(--primary-color);
color: white;
}
.form-input {
width: 100%;
padding: 12px 16px;
border: 2px solid var(--border-color);
border-radius: 8px;
font-size: 16px;
transition: border-color 0.2s ease;
}
.form-input:focus {
outline: none;
border-color: var(--primary-color);
}
.form-input.error {
border-color: var(--success-color);
}
.error-message {
color: var(--success-color);
font-size: 14px;
margin-top: 4px;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--primary-color);
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-color);
border-top: 2px solid var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.toast {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: var(--text-color);
color: white;
padding: 12px 24px;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
z-index: 1000;
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
transform: translateX(-50%) translateY(100%);
opacity: 0;
}
to {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--card-background);
border-radius: 12px;
padding: 24px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.offline-indicator {
position: fixed;
top: 0;
left: 0;
width: 100%;
background: var(--warning-color);
color: white;
text-align: center;
padding: 8px;
z-index: 1001;
}
.pull-to-refresh {
position: relative;
padding-top: 60px;
transition: padding-top 0.3s ease;
}
.pull-to-refresh.refreshing {
padding-top: 80px;
}
.pull-to-refresh-indicator {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
color: var(--primary-color);
}
</style>
</head>
<body>
<!-- Header -->
<header class="bg-white shadow-md sticky top-0 z-50">
<div class="container">
<div class="flex items-center justify-between h-16">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
</div>
<h1 class="text-xl font-bold text-gray-900">PWA Móvil</h1>
</div>
<div class="flex items-center gap-2">
<button id="syncBtn" class="p-2 rounded-lg hover:bg-gray-100">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
<button id="menuBtn" class="p-2 rounded-lg hover:bg-gray-100">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="container py-6">
<!-- Stats Section -->
<section class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="card text-center">
<div class="text-2xl font-bold text-blue-500" id="totalUsers">0</div>
<div class="text-sm text-gray-600">Usuarios</div>
</div>
<div class="card text-center">
<div class="text-2xl font-bold text-green-500" id="activeUsers">0</div>
<div class="text-sm text-gray-600">Activos</div>
</div>
<div class="card text-center">
<div class="text-2xl font-bold text-purple-500" id="totalOrders">0</div>
<div class="text-sm text-gray-600">Pedidos</div>
</div>
<div class="card text-center">
<div class="text-2xl font-bold text-orange-500" id="revenue">€0</div>
<div class="text-sm text-gray-600">Ingresos</div>
</div>
</section>
<!-- Actions Section -->
<section class="mb-6">
<div class="flex flex-wrap gap-2">
<button id="addUserBtn" class="btn btn-primary">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Agregar usuario
</button>
<button id="filterBtn" class="btn btn-outline">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.293H3a1 1 0 01-1-1V4z"/>
</svg>
Filtrar
</button>
<button id="sortBtn" class="btn btn-outline">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4h12"/>
</svg>
Ordenar
</button>
</div>
</section>
<!-- Search Section -->
<section class="mb-6">
<div class="relative">
<input
type="text"
id="searchInput"
placeholder="Buscar usuarios..."
class="form-input pl-10"
>
<svg class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
</section>
<!-- Users List -->
<section id="usersList" class="pull-to-refresh">
<div class="pull-to-refresh-indicator" style="display: none;">
<div class="spinner"></div>
<span>Cargando usuarios...</span>
</div>
<div id="usersContainer">
<!-- Users will be dynamically added here -->
</div>
</section>
</main>
<!-- User Form Modal -->
<div id="userModal" class="modal" style="display: none;">
<div class="modal-content">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold">Agregar usuario</h2>
<button id="closeModalBtn" class="p-2 rounded-lg hover:bg-gray-100">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<form id="userForm">
<div class="mb-4">
<label class="block text-sm font-medium mb-2">Nombre *</label>
<input type="text" id="userName" class="form-input" required>
<div class="error-message" id="nameError"></div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2">Correo electrónico *</label>
<input type="email" id="userEmail" class="form-input" required>
<div class="error-message" id="emailError"></div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2">Teléfono *</label>
<input type="tel" id="userPhone" class="form-input" required>
<div class="error-message" id="phoneError"></div>
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-2">Dirección *</label>
<textarea id="userAddress" class="form-input" rows="3" required></textarea>
<div class="error-message" id="addressError"></div>
</div>
<div class="flex gap-2 justify-end">
<button type="button" id="cancelBtn" class="btn btn-outline">Cancelar</button>
<button type="submit" id="saveBtn" class="btn btn-primary">
<span id="saveBtnText">Guardar</span>
<div class="spinner" style="display: none;"></div>
</button>
</div>
</form>
</div>
</div>
<!-- Toast Container -->
<div id="toastContainer"></div>
<!-- JavaScript -->
<script>
// App State
const app = {
users: [],
isLoading: false,
isOnline: navigator.onLine,
editingUser: null,
// Initialize app
init() {
this.bindEvents();
this.checkOnlineStatus();
this.loadUsers();
this.updateStats();
},
// Event binding
bindEvents() {
// Header buttons
document.getElementById('syncBtn').addEventListener('click', () => this.syncData());
document.getElementById('menuBtn').addEventListener('click', () => this.toggleMenu());
// Action buttons
document.getElementById('addUserBtn').addEventListener('click', () => this.showUserModal());
document.getElementById('filterBtn').addEventListener('click', () => this.showFilterOptions());
document.getElementById('sortBtn').addEventListener('click', () => this.showSortOptions());
// Search
document.getElementById('searchInput').addEventListener('input', (e) => this.searchUsers(e.target.value));
// Modal
document.getElementById('closeModalBtn').addEventListener('click', () => this.hideUserModal());
document.getElementById('cancelBtn').addEventListener('click', () => this.hideUserModal());
document.getElementById('userForm').addEventListener('submit', (e) => this.saveUser(e));
// Pull to refresh
this.initPullToRefresh();
// Online status
window.addEventListener('online', () => this.checkOnlineStatus());
window.addEventListener('offline', () => this.checkOnlineStatus());
},
// User Management
async loadUsers() {
this.setLoading(true);
try {
if (this.isOnline) {
// Try to fetch from server
const users = await this.fetchUsersFromServer();
this.users = users;
this.saveUsersToCache(users);
} else {
// Load from cache
const users = await this.getUsersFromCache();
this.users = users;
}
this.renderUsers();
} catch (error) {
console.error('Error loading users:', error);
this.showToast('Error al cargar usuarios', 'error');
} finally {
this.setLoading(false);
}
},
async fetchUsersFromServer() {
// Simulate API call
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{
id: '1',
name: 'Max Mustermann',
email: 'max@example.com',
phone: '+49 123 456789',
address: 'Musterstraße 1, 12345 Berlin',
status: 'active',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '2',
name: 'Erika Mustermann',
email: 'erika@example.com',
phone: '+49 987 654321',
address: 'Beispielweg 2, 54321 Hamburg',
status: 'active',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
]);
}, 1000);
});
},
async saveUsersToCache(users) {
try {
const cache = await caches.open('users-cache-v1');
const response = new Response(JSON.stringify(users));
await cache.put('/users', response);
} catch (error) {
console.error('Error saving to cache:', error);
}
},
async getUsersFromCache() {
try {
const cache = await caches.open('users-cache-v1');
const response = await cache.match('/users');
if (response) {
return await response.json();
}
} catch (error) {
console.error('Error loading from cache:', error);
}
return [];
},
renderUsers() {
const container = document.getElementById('usersContainer');
if (this.users.length === 0) {
container.innerHTML = `
<div class="card text-center py-12">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<p class="text-gray-600">No se encontraron usuarios</p>
<button class="btn btn-primary mt-4" onclick="app.showUserModal()">
Agregar primer usuario
</button>
</div>
`;
return;
}
container.innerHTML = this.users.map(user => `
<div class="card">
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center">
<span class="text-white font-bold">${user.name.charAt(0).toUpperCase()}</span>
</div>
<div>
<h3 class="font-semibold">${user.name}</h3>
<p class="text-sm text-gray-600">${user.email}</p>
<p class="text-sm text-gray-600">${user.phone}</p>
<p class="text-sm text-gray-600">${user.address}</p>
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-outline" onclick="app.editUser('${user.id}')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-4h-4l-2.828 2.828z"/>
</svg>
</button>
<button class="btn btn-outline" onclick="app.deleteUser('${user.id}')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</div>
</div>
`).join('');
},
async saveUser(event) {
event.preventDefault();
const formData = {
name: document.getElementById('userName').value,
email: document.getElementById('userEmail').value,
phone: document.getElementById('userPhone').value,
address: document.getElementById('userAddress').value
};
if (!this.validateUserForm(formData)) {
return;
}
this.setLoading(true, true);
try {
if (this.editingUser) {
// Update existing user
const index = this.users.findIndex(u => u.id === this.editingUser);
this.users[index] = {
...this.users[index],
...formData,
updatedAt: new Date().toISOString()
};
this.showToast('Usuario actualizado', 'success');
} else {
// Create new user
const newUser = {
id: Date.now().toString(),
...formData,
status: 'active',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
this.users.push(newUser);
this.showToast('Usuario creado', 'success');
}
await this.saveUsersToCache(this.users);
this.renderUsers();
this.updateStats();
this.hideUserModal();
if (this.isOnline) {
// Sync with server
this.syncData();
}
} catch (error) {
console.error('Error saving user:', error);
this.showToast('Error al guardar', 'error');
} finally {
this.setLoading(false, true);
}
},
validateUserForm(data) {
let isValid = true;
// Clear previous errors
document.querySelectorAll('.error-message').forEach(el => el.textContent = '');
document.querySelectorAll('.form-input').forEach(el => el.classList.remove('error'));
// Validate name
if (!data.name.trim()) {
document.getElementById('nameError').textContent = 'El nombre es requerido';
document.getElementById('userName').classList.add('error');
isValid = false;
}
// Validate email
if (!data.email.trim()) {
document.getElementById('emailError').textContent = 'El correo es requerido';
document.getElementById('userEmail').classList.add('error');
isValid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
document.getElementById('emailError').textContent = 'Correo electrónico inválido';
document.getElementById('userEmail').classList.add('error');
isValid = false;
}
// Validate phone
if (!data.phone.trim()) {
document.getElementById('phoneError').textContent = 'El teléfono es requerido';
document.getElementById('userPhone').classList.add('error');
isValid = false;
}
// Validate address
if (!data.address.trim()) {
document.getElementById('addressError').textContent = 'La dirección es requerida';
document.getElementById('userAddress').classList.add('error');
isValid = false;
}
return isValid;
},
editUser(userId) {
const user = this.users.find(u => u.id === userId);
if (user) {
this.editingUser = userId;
document.getElementById('userName').value = user.name;
document.getElementById('userEmail').value = user.email;
document.getElementById('userPhone').value = user.phone;
document.getElementById('userAddress').value = user.address;
document.querySelector('#userModal h2').textContent = 'Editar usuario';
this.showUserModal();
}
},
async deleteUser(userId) {
if (!confirm('¿Deseas eliminar este usuario?')) {
return;
}
try {
this.users = this.users.filter(u => u.id !== userId);
await this.saveUsersToCache(this.users);
this.renderUsers();
this.updateStats();
this.showToast('Usuario eliminado', 'success');
if (this.isOnline) {
// Sync with server
this.syncData();
}
} catch (error) {
console.error('Error deleting user:', error);
this.showToast('Error al
### 4. Service Worker para PWA
```javascript
// sw.js - Service Worker para Progressive Web App
const CACHE_NAME = 'mobile-pwa-v1';
const urlsToCache = [
'/',
'/index.html',
'/manifest.json',
'/icons/icon-192x192.png',
'/icons/icon-512x512.png',
// Add other static assets here
];
// Install Event - Cache static assets
self.addEventListener('install', (event) => {
console.log('SW: Installing...');
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('SW: Caching static assets');
return cache.addAll(urlsToCache);
})
.then(() => {
console.log('SW: Static assets cached successfully');
return self.skipWaiting();
})
.catch((error) => {
console.error('SW: Failed to cache static assets:', error);
})
);
});
// Activate Event - Clean up old caches
self.addEventListener('activate', (event) => {
console.log('SW: Activating...');
event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
console.log('SW: Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
.then(() => {
console.log('SW: Old caches deleted successfully');
return self.clients.claim();
})
.catch((error) => {
console.error('SW: Failed to delete old caches:', error);
})
);
});
// Fetch Event - Network-first with cache fallback
self.addEventListener('fetch', (event) => {
const request = event.request;
const url = new URL(request.url);
// Skip non-GET requests and external requests
if (request.method !== 'GET' || url.origin !== self.location.origin) {
return;
}
event.respondWith(
caches.match(request)
.then((response) => {
// Return cached version if available
if (response) {
console.log('SW: Serving from cache:', request.url);
return response;
}
// Otherwise fetch from network
console.log('SW: Fetching from network:', request.url);
return fetch(request)
.then((response) => {
// Cache successful responses
if (response.status === 200) {
const responseClone = response.clone();
caches.open(CACHE_NAME)
.then((cache) => {
console.log('SW: Caching new response:', request.url);
cache.put(request, responseClone);
})
.catch((error) => {
console.error('SW: Failed to cache response:', error);
});
}
return response;
})
.catch((error) => {
console.error('SW: Network request failed:', error);
// Try to serve from cache if network fails
return caches.match(request)
.then((cachedResponse) => {
if (cachedResponse) {
console.log('SW: Network failed, serving from cache:', request.url);
return cachedResponse;
}
// Return offline page if nothing in cache
return caches.match('/offline.html');
});
});
})
);
});
// Background Sync for offline actions
self.addEventListener('sync', (event) => {
console.log('SW: Background sync triggered:', event.tag);
if (event.tag === 'background-sync-users') {
event.waitUntil(
syncUsersData()
.then(() => {
console.log('SW: Background sync completed');
})
.catch((error) => {
console.error('SW: Background sync failed:', error);
})
);
}
});
// Push Notifications
self.addEventListener('push', (event) => {
console.log('SW: Push message received');
const options = {
body: event.data.text(),
icon: '/icons/icon-192x192.png',
badge: '/icons/icon-192x192.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: 1
},
actions: [
{
action: 'explore',
title: 'Explore',
icon: '/images/checkmark.png'
},
{
action: 'close',
title: 'Close',
icon: '/images/xmark.png'
}
]
};
event.waitUntil(
self.registration.showNotification('Mobile PWA', options)
);
});
// Notification Click Handling
self.addEventListener('notificationclick', (event) => {
console.log('SW: Notification clicked');
event.notification.close();
if (event.action === 'explore') {
event.waitUntil(
clients.openWindow('/')
);
}
});
// Message Handling
self.addEventListener('message', (event) => {
console.log('SW: Message received:', event.data);
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Helper Functions
async function syncUsersData() {
try {
// Get all pending user actions from IndexedDB
const pendingActions = await getPendingActions();
for (const action of pendingActions) {
try {
// Sync with server
await syncActionWithServer(action);
// Remove from pending actions
await removePendingAction(action.id);
// Notify client about successful sync
notifyClient('sync-success', { actionId: action.id });
} catch (error) {
console.error('SW: Failed to sync action:', action, error);
notifyClient('sync-error', { actionId: action.id, error: error.message });
}
}
} catch (error) {
console.error('SW: Background sync failed:', error);
throw error;
}
}
async function getPendingActions() {
// IndexedDB implementation for pending actions
return new Promise((resolve, reject) => {
const request = indexedDB.open('offline-actions', 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction(['actions'], 'readonly');
const store = transaction.objectStore('actions');
const getRequest = store.getAll();
getRequest.onerror = () => reject(getRequest.error);
getRequest.onsuccess = () => resolve(getRequest.result);
};
});
}
async function syncActionWithServer(action) {
const response = await fetch('/api/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(action)
});
if (!response.ok) {
throw new Error(`Sync failed: ${response.status}`);
}
return response.json();
}
async function removePendingAction(actionId) {
return new Promise((resolve, reject) => {
const request = indexedDB.open('offline-actions', 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction(['actions'], 'readwrite');
const store = transaction.objectStore('actions');
const deleteRequest = store.delete(actionId);
deleteRequest.onerror = () => reject(deleteRequest.error);
deleteRequest.onsuccess = () => resolve();
};
});
}
function notifyClient(type, data) {
return self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({ type, data });
});
});
}
// Cache Strategy Helpers
const cacheStrategies = {
// Network first, falling back to cache
networkFirst: (request) => {
return fetch(request)
.then(response => {
// Cache successful responses
if (response.ok) {
const responseClone = response.clone();
caches.open(CACHE_NAME)
.then(cache => cache.put(request, responseClone));
}
return response;
})
.catch(() => caches.match(request));
},
// Cache first, falling back to network
cacheFirst: (request) => {
return caches.match(request)
.then(response => {
if (response) {
return response;
}
// Fetch from network and cache
return fetch(request)
.then(response => {
if (response.ok) {
const responseClone = response.clone();
caches.open(CACHE_NAME)
.then(cache => cache.put(request, responseClone));
}
return response;
});
});
},
// Stale while revalidate
staleWhileRevalidate: (request) => {
return caches.match(request)
.then(response => {
const fetchPromise = fetch(request)
.then(networkResponse => {
if (networkResponse.ok) {
const networkResponseClone = networkResponse.clone();
caches.open(CACHE_NAME)
.then(cache => cache.put(request, networkResponseClone));
}
return networkResponse;
});
return response || fetchPromise;
});
},
// Network only
networkOnly: (request) => {
return fetch(request);
},
// Cache only
cacheOnly: (request) => {
return caches.match(request);
}
};
// Route-specific cache strategies
const router = {
register: (routes, strategy) => {
routes.forEach(route => {
if (typeof route === 'string') {
self.addEventListener('fetch', event => {
if (event.request.url === route) {
event.respondWith(strategy(event.request));
}
});
} else if (route instanceof RegExp) {
self.addEventListener('fetch', event => {
if (route.test(event.request.url)) {
event.respondWith(strategy(event.request));
}
});
}
});
}
};
// Register routes with specific strategies
router.register([
'/api/users',
/^\/api\/users\/\d+$/
], cacheStrategies.networkFirst);
router.register([
'/static/',
/\.(js|css|png|jpg|jpeg|svg|gif)$/
], cacheStrategies.cacheFirst);
router.register([
'/'
], cacheStrategies.staleWhileRevalidate);
console.log('SW: Service Worker loaded successfully');
Comparativa de enfoques de desarrollo móvil
| Criterio | iOS nativo (Swift) | Android nativo (Kotlin) | React Native | Flutter | PWA |
|---|---|---|---|---|---|
| Rendimiento | Óptimo | Óptimo | Bueno | Muy bueno | Bueno |
| UI/UX | Estándar de plataforma | Estándar de plataforma | Multiplataforma | Multiplataforma | Estándar web |
| Desarrollo | Específico de plataforma | Específico de plataforma | JavaScript | Dart | Tecnologías web |
| Reutilización de código | Ninguna | Ninguna | 90%+ | 95%+ | 100% |
| Acceso a hardware | Completo | Completo | Limitado | Limitado | Muy limitado |
| App Store | Sí | Sí | Sí | Sí | No |
| Funcionamiento offline | Sí | Sí | Sí | Sí | Sí |
| Notificaciones push | Sí | Sí | Sí | Sí | Sí |
| Proceso de actualización | App Store | Play Store | Over-the-Air | Over-the-Air | Automático |
Comparativa de frameworks multiplataforma
React Native vs. Flutter
| Característica | React Native | Flutter |
|---|---|---|
| Lenguaje | JavaScript/TypeScript | Dart |
| Renderizado UI | Componentes nativos | Motor personalizado |
| Rendimiento | Bueno | Muy bueno |
| Hot Reload | Sí | Sí |
| Comunidad | Grande | En crecimiento |
| Curva de aprendizaje | Media | Media |
| Ecosistema | React |
Otras soluciones multiplataforma
- Xamarin: C# con .NET (Microsoft)
- Ionic: Tecnologías web con Capacitor
- Cordova: Envoltorio de WebView
- Qt: Framework de C++
- Unity: C# para 3D/Juegos
Características y capacidades de PWA
Funcionalidades principales de PWA
// Registro de Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(registration => console.log('SW registered'))
.catch(error => console.log('SW registration failed'));
}
// Aviso de instalación
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
showInstallButton();
});
// Sincronización en segundo plano
if ('serviceWorker' in navigator && 'SyncManager' in window) {
registration.sync.register('background-sync');
}
Limitaciones de PWA
- iOS Safari: Soporte limitado
- Acceso a hardware: Cámara, GPS con restricciones
- Rendimiento: Más lento que aplicaciones nativas
- Almacenamiento: Capacidad offline limitada
- Notificaciones push: No disponible en todas las plataformas
Mejores prácticas de UI/UX móvil
Diseño responsivo
/* Diseño responsivo mobile-first */
.container {
max-width: 100%;
padding: 0 16px;
margin: 0 auto;
}
@media (min-width: 768px) {
.container {
max-width: 768px;
}
}
/* Botones fáciles de tocar */
.btn {
min-height: 44px;
min-width: 44px;
padding: 12px 16px;
}
Optimización de rendimiento
- Carga perezosa: Imágenes y componentes
- Separación de código: Imports dinámicos
- Tamaño del bundle: Minimización y compresión
- Optimización de imágenes: WebP, imágenes responsivas
- Almacenamiento en caché: Service Worker y HTTP caching
Estrategias de testing móvil
Automatización de pruebas
// Testing en React Native
import { render, fireEvent } from '@testing-library/react-native';
import App from './App';
test('renders correctly', () => {
const { getByText } = render(<App />);
expect(getByText('Welcome')).toBeTruthy();
});
// Testing en Flutter
import 'package:flutter_test.dart';
void main() {
testWidgetsTest((WidgetTester tester) async {
await tester.pumpWidget(MyApp());
expect(find.text('Welcome'), findsOneWidget);
});
}
Testing en dispositivos
- Emuladores: iOS Simulator, Android Emulator
- Dispositivos reales: Varios modelos y versiones de SO
- Testing en la nube: BrowserStack, Sauce Labs
- Beta testing: TestFlight, Google Play Beta
Mejores prácticas de seguridad móvil
Protección de datos
// Almacenamiento seguro
import { SecureStore } from 'expo-secure-store';
// Guardar datos sensibles de forma segura
await SecureStore.setItemAsync('userToken', token, {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
// Encriptación en tránsito
const https = require('https');
const crypto = require('crypto');
// Encriptar datos antes del almacenamiento
function encryptData(data) {
const algorithm = 'aes-256-gcm';
const key = crypto.randomBytes(32);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipher(algorithm, key, iv);
let encrypted = cipher.update(data, 'utf8', 'hex');
encrypted += cipher.final('hex');
return {
encrypted,
key: key.toString('hex'),
iv: iv.toString('hex')
};
}
Autenticación
- Biométrica: Touch ID, Face ID
- OAuth 2.0: Flujos de autenticación seguros
- JWT: Autenticación basada en tokens
- 2FA: Autenticación de dos factores
- Gestión de sesiones: Manejo seguro de sesiones
Optimización de rendimiento móvil
Métricas de rendimiento
// Monitoreo de rendimiento
import { performance } from 'perf_hooks';
// Medir rendimiento de renderizado
const start = performance.now();
// ... render component
const end = performance.now();
console.log(`Render took ${end - start} milliseconds`);
// Monitoreo de memoria
const memoryInfo = performance.memory;
console.log('Memory usage:', memoryInfo);
Técnicas de optimización
- Listas virtuales: Para grandes volúmenes de datos
- Optimización de imágenes: Carga perezosa, WebP
- Optimización de bundle: Tree shaking, separación de código
- Gestión de memoria: Prevención de fugas
- Optimización de red: Agrupación de requests, almacenamiento en caché
Ventajas e inconvenientes
Ventajas del desarrollo multiplataforma
- Eficiencia de costos: Una base de código para todas las plataformas
- Desarrollo más rápido: Lanzamiento al mercado más ágil
- Consistencia: Experiencia de usuario uniforme
- Mantenimiento: Actualizaciones más sencillas
- Habilidades del equipo: Desarrolladores de JavaScript/Dart
Inconvenientes
- Rendimiento: No siempre óptimo
- Limitaciones de plataforma: Acceso limitado a APIs
- Debugging: Análisis de errores más complejo
- Retrasos en actualizaciones: Dependencia de actualizaciones del framework
- Tamaño: Bundles de aplicación más grandes
Preguntas de examen frecuentes
-
¿Cuándo elegir nativo vs. multiplataforma? Nativo cuando hay requisitos altos de rendimiento. Multiplataforma cuando se busca lanzamiento rápido y presupuesto limitado.
-
¿Cuáles son las principales ventajas de las PWA? Sin dependencia de app store, actualizaciones automáticas, basadas en web, funcionamiento offline, económicas.
-
Explica qué son los Service Workers. Service Workers son scripts de JavaScript que se ejecutan en segundo plano, permitiendo capacidades offline, almacenamiento en caché y notificaciones push.
-
¿Cómo se optimiza el rendimiento de una aplicación móvil? Carga perezosa, optimización de bundles, gestión de memoria, módulos nativos para rutas críticas, monitoreo de rendimiento.
Principales referencias
- https://reactnative.dev/
- https://flutter.dev/
- https://developer.apple.com/swift/
- https://developer.android.com/kotlin/



