Práctica: Consumir un API desde una aplicación Flutter
Descripción general
Esta práctica consiste en desarrollar una aplicación móvil de gestión de tareas (Task Manager) usando Flutter/Dart, que consume un API REST con autenticación JWT. La app permite a un usuario autenticado realizar operaciones CRUD completas sobre sus tareas, filtrarlas por estado, buscarlas y cambiar su estado de avance.
Objetivo del proyecto
Que el estudiante sea capaz de:
- Configurar y consumir un API REST desde Flutter usando el paquete
http. - Implementar autenticación con JWT Bearer Token (login, logout, me, refresh).
- Persisitir datos localmente (token y host) con
shared_preferences. - Organizar el código en capas: modelos, servicios y pantallas.
- Manejar estados de carga, error y datos vacíos en la UI.
- Navegar entre pantallas y pasar datos de retorno con
Navigator.
Aspectos importantes
- La URL base del API es configurable en tiempo de ejecución desde la pantalla de configuración, lo que permite apuntar la app a diferentes entornos (local, staging, producción) sin recompilar.
- Se usa
StatefulWidgetde forma directa (sin gestor de estado externo) para mantener la práctica accesible para estudiantes que comienzan. - El
ApiServicecentraliza toda la comunicación HTTP con la API y lanza excepciones tipadas (ApiException) cuando el servidor responde con código ≥ 400. - El
StorageServiceabstrae completamente el acceso aSharedPreferences, siguiendo el principio de responsabilidad única. - La navegación usa
Navigator.pushretornando objetos (Tasko la cadena'deleted') para que la pantalla anterior actualice su lista sin hacer una nueva petición al servidor.
Requisitos previos
| Herramienta | Versión mínima |
|---|---|
| Flutter SDK | 3.22+ |
| Dart SDK | 3.4+ (incluido con Flutter) |
| Android Studio / VS Code | Cualquier versión reciente con plugin Flutter |
| API REST con autenticación JWT | Ver sección de endpoints |
URLs del API
| Entorno | URL base |
|---|---|
| Internet | https://demoapi.evo-code.com |
| Centro de cómputo (red local) | http://192.168.5.10:8081 |
La URL base es configurable en tiempo de ejecución desde la pantalla de Configuración de la app, sin necesidad de recompilar. Al guardar un nuevo host, el ApiService lo utiliza de inmediato en todas las peticiones posteriores.
Endpoints del API utilizados
| Método | Endpoint | Descripción | Auth | Body (JSON) |
|---|---|---|---|---|
POST |
/api/auth/register |
Registro de usuario | No | name, email, password, password_confirmation |
POST |
/api/auth/login |
Login, retorna access_token |
No | email, password |
POST |
/api/auth/logout |
Cerrar sesión | 🔒 Bearer Token | — |
GET |
/api/auth/me |
Usuario autenticado | 🔒 Bearer Token | — |
POST |
/api/auth/refresh |
Refrescar token | 🔒 Bearer Token | — |
GET |
/api/tasks |
Listar tareas del usuario | 🔒 Bearer Token | — |
POST |
/api/tasks |
Crear tarea | 🔒 Bearer Token | title, body |
GET |
/api/tasks/{id} |
Obtener tarea por ID | 🔒 Bearer Token | — |
PUT |
/api/tasks/{id} |
Actualizar título y cuerpo | 🔒 Bearer Token | title (opc.), body (opc.) |
PATCH |
/api/tasks/{id}/status |
Cambiar estado | 🔒 Bearer Token | status |
DELETE |
/api/tasks/{id} |
Eliminar tarea | 🔒 Bearer Token | — |
GET |
/api/tasks/search?q=&status= |
Buscar tareas | 🔒 Bearer Token | — (query params: q, status) |
Los estados válidos de una tarea son: nueva, en_proceso, completada.
Estructura de directorios
api_client/
├── lib/
│ ├── main.dart # Punto de entrada, shell principal con NavigationBar
│ ├── models/
│ │ ├── task.dart # Modelo de datos: Task
│ │ └── user.dart # Modelo de datos: User
│ ├── services/
│ │ ├── api_service.dart # Capa HTTP: todos los llamados al API REST
│ │ └── storage_service.dart # Persistencia local: token y host
│ └── screens/
│ ├── tasks_screen.dart # Lista de tareas con búsqueda y filtros
│ ├── task_detail_screen.dart # Detalle de una tarea, cambio de estado
│ ├── task_form_screen.dart # Formulario crear/editar tarea
│ └── settings_screen.dart # Configuración de host y autenticación
├── pubspec.yaml
└── analysis_options.yaml
Librerías utilizadas
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
http: ^1.2.2 # Peticiones HTTP al API REST
shared_preferences: ^2.3.3 # Persistencia local (token JWT y URL del host)
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
| Paquete | Propósito |
|---|---|
http |
Realizar peticiones GET, POST, PUT, PATCH y DELETE al API REST |
shared_preferences |
Guardar y recuperar el token JWT y la URL base del API entre sesiones |
Pasos para desarrollar el proyecto
Paso 1 — Crear el proyecto Flutter
flutter create api_client
cd api_client
Paso 2 — Agregar dependencias
Editar pubspec.yaml y agregar bajo dependencies:
http: ^1.2.2
shared_preferences: ^2.3.3
Luego ejecutar:
flutter pub get
Paso 3 — Crear los modelos de datos
Los modelos representan las entidades que el API retorna en formato JSON.
lib/models/user.dart
class User {
final int id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'],
name: json['name'],
email: json['email'],
);
}
Puntos clave:
- El constructor factory User.fromJson convierte el Map que devuelve jsonDecode en un objeto Dart tipado.
- Se sigue el patrón de modelo inmutable: todos los campos son final.
lib/models/task.dart
class Task {
final int id;
final int userId;
final String title;
final String body;
final String status;
final DateTime createdAt;
final DateTime updatedAt;
Task({
required this.id,
required this.userId,
required this.title,
required this.body,
required this.status,
required this.createdAt,
required this.updatedAt,
});
factory Task.fromJson(Map<String, dynamic> json) => Task(
id: json['id'],
userId: json['user_id'],
title: json['title'],
body: json['body'],
status: json['status'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
);
Task copyWith({
String? title,
String? body,
String? status,
}) =>
Task(
id: id,
userId: userId,
title: title ?? this.title,
body: body ?? this.body,
status: status ?? this.status,
createdAt: createdAt,
updatedAt: updatedAt,
);
}
Puntos clave:
- DateTime.parse(json['created_at']) convierte el string ISO 8601 del API en DateTime.
- copyWith permite crear una copia del objeto modificando solo algunos campos, útil al actualizar la UI localmente sin volver a llamar al API.
Paso 4 — Crear el servicio de almacenamiento local
lib/services/storage_service.dart
import 'package:shared_preferences/shared_preferences.dart';
class StorageService {
static const _keyToken = 'jwt_token';
static const _keyHost = 'api_host';
static Future<void> saveToken(String token) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyToken, token);
}
static Future<String?> getToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyToken);
}
static Future<void> clearToken() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyToken);
}
static Future<void> saveHost(String host) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyHost, host);
}
static Future<String> getHost() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyHost) ?? 'http://localhost:8000';
}
}
Puntos clave:
- Todos los métodos son static para usarlos sin instanciar la clase.
- getHost() devuelve un valor por defecto (localhost:8000) cuando no hay nada guardado.
- El token se guarda como String y se elimina (no se pone en null) al cerrar sesión.
Paso 5 — Crear el servicio del API
lib/services/api_service.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'storage_service.dart';
class ApiException implements Exception {
final int statusCode;
final String message;
ApiException(this.statusCode, this.message);
@override
String toString() => 'ApiException($statusCode): $message';
}
class ApiService {
static Future<String> _baseUrl() async {
final host = await StorageService.getHost();
return '$host/api';
}
static Future<Map<String, String>> _authHeaders() async {
final token = await StorageService.getToken();
return {
'Content-Type': 'application/json',
'Accept': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
};
}
static void _check(http.Response res) {
if (res.statusCode >= 400) {
String msg;
try {
final body = jsonDecode(res.body);
msg = body['message'] ?? res.body;
} catch (_) {
msg = res.body;
}
throw ApiException(res.statusCode, msg);
}
}
// ── Auth ──────────────────────────────────────────────────────────────────
static Future<Map<String, dynamic>> register({
required String name,
required String email,
required String password,
required String passwordConfirmation,
}) async {
final base = await _baseUrl();
final res = await http.post(
Uri.parse('$base/auth/register'),
headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
body: jsonEncode({
'name': name,
'email': email,
'password': password,
'password_confirmation': passwordConfirmation,
}),
);
_check(res);
return jsonDecode(res.body);
}
static Future<Map<String, dynamic>> login({
required String email,
required String password,
}) async {
final base = await _baseUrl();
final res = await http.post(
Uri.parse('$base/auth/login'),
headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
body: jsonEncode({'email': email, 'password': password}),
);
_check(res);
return jsonDecode(res.body);
}
static Future<void> logout() async {
final base = await _baseUrl();
final headers = await _authHeaders();
final res = await http.post(Uri.parse('$base/auth/logout'), headers: headers);
_check(res);
await StorageService.clearToken();
}
static Future<Map<String, dynamic>> me() async {
final base = await _baseUrl();
final headers = await _authHeaders();
final res = await http.get(Uri.parse('$base/auth/me'), headers: headers);
_check(res);
return jsonDecode(res.body);
}
static Future<Map<String, dynamic>> refreshToken() async {
final base = await _baseUrl();
final headers = await _authHeaders();
final res = await http.post(Uri.parse('$base/auth/refresh'), headers: headers);
_check(res);
return jsonDecode(res.body);
}
// ── Tasks ─────────────────────────────────────────────────────────────────
static Future<List<dynamic>> getTasks() async {
final base = await _baseUrl();
final headers = await _authHeaders();
final res = await http.get(Uri.parse('$base/tasks'), headers: headers);
_check(res);
return jsonDecode(res.body);
}
static Future<Map<String, dynamic>> createTask({
required String title,
required String body,
}) async {
final base = await _baseUrl();
final headers = await _authHeaders();
final res = await http.post(
Uri.parse('$base/tasks'),
headers: headers,
body: jsonEncode({'title': title, 'body': body}),
);
_check(res);
return jsonDecode(res.body);
}
static Future<Map<String, dynamic>> getTask(int id) async {
final base = await _baseUrl();
final headers = await _authHeaders();
final res = await http.get(Uri.parse('$base/tasks/$id'), headers: headers);
_check(res);
return jsonDecode(res.body);
}
static Future<Map<String, dynamic>> updateTask(
int id, {
String? title,
String? body,
}) async {
final base = await _baseUrl();
final headers = await _authHeaders();
final payload = <String, dynamic>{};
if (title != null) payload['title'] = title;
if (body != null) payload['body'] = body;
final res = await http.put(
Uri.parse('$base/tasks/$id'),
headers: headers,
body: jsonEncode(payload),
);
_check(res);
return jsonDecode(res.body);
}
static Future<Map<String, dynamic>> updateTaskStatus(
int id,
String status,
) async {
final base = await _baseUrl();
final headers = await _authHeaders();
final res = await http.patch(
Uri.parse('$base/tasks/$id/status'),
headers: headers,
body: jsonEncode({'status': status}),
);
_check(res);
return jsonDecode(res.body);
}
static Future<void> deleteTask(int id) async {
final base = await _baseUrl();
final headers = await _authHeaders();
final res = await http.delete(Uri.parse('$base/tasks/$id'), headers: headers);
_check(res);
}
static Future<List<dynamic>> searchTasks({String? q, String? status}) async {
final base = await _baseUrl();
final headers = await _authHeaders();
final params = <String, String>{};
if (q != null && q.isNotEmpty) params['q'] = q;
if (status != null && status != 'todas') params['status'] = status;
final uri = Uri.parse('$base/tasks/search').replace(queryParameters: params);
final res = await http.get(uri, headers: headers);
_check(res);
return jsonDecode(res.body);
}
}
Puntos clave:
- _check(res) valida la respuesta HTTP centralizando el manejo de errores. Si el servidor retorna ≥ 400, intenta parsear el campo message del JSON de error.
- _authHeaders() construye los headers incluyendo Authorization: Bearer <token> solo si existe un token guardado (sintaxis de colección if de Dart).
- _baseUrl() lee el host en cada llamada, por lo que si el usuario lo cambia en configuración se refleja inmediatamente.
- Se usa Uri.parse(...).replace(queryParameters: params) para construir URLs con query params de forma segura.
Paso 6 — Crear las pantallas
lib/screens/settings_screen.dart
Pantalla de configuración con dos secciones: conexión al API (host) y autenticación (login/logout).
import 'package:flutter/material.dart';
import '../services/api_service.dart';
import '../services/storage_service.dart';
class SettingsScreen extends StatefulWidget {
final VoidCallback? onLoginSuccess;
const SettingsScreen({super.key, this.onLoginSuccess});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final _hostController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _loading = false;
bool _obscurePassword = true;
String? _savedHost;
bool _isLoggedIn = false;
String? _userName;
@override
void initState() {
super.initState();
_loadSettings();
}
Future<void> _loadSettings() async {
final host = await StorageService.getHost();
final token = await StorageService.getToken();
setState(() {
_hostController.text = host;
_savedHost = host;
_isLoggedIn = token != null;
});
if (token != null) {
_fetchMe();
}
}
Future<void> _fetchMe() async {
try {
final data = await ApiService.me();
setState(() => _userName = data['name']);
} catch (_) {
setState(() {
_isLoggedIn = false;
_userName = null;
});
}
}
Future<void> _saveHost() async {
final host = _hostController.text.trim();
if (host.isEmpty) return;
await StorageService.saveHost(host);
setState(() => _savedHost = host);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Host guardado correctamente')),
);
}
}
Future<void> _login() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _loading = true);
try {
final data = await ApiService.login(
email: _emailController.text.trim(),
password: _passwordController.text,
);
await StorageService.saveToken(data['access_token']);
setState(() {
_isLoggedIn = true;
_loading = false;
});
await _fetchMe();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sesión iniciada correctamente')),
);
widget.onLoginSuccess?.call();
}
} catch (e) {
setState(() => _loading = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString()), backgroundColor: Colors.red),
);
}
}
}
Future<void> _logout() async {
setState(() => _loading = true);
try {
await ApiService.logout();
} catch (_) {
await StorageService.clearToken();
}
setState(() {
_isLoggedIn = false;
_userName = null;
_loading = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sesión cerrada')),
);
}
}
@override
void dispose() {
_hostController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Configuración'),
backgroundColor: theme.colorScheme.inversePrimary,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Conexión al API', style: theme.textTheme.titleLarge),
const SizedBox(height: 12),
TextFormField(
controller: _hostController,
decoration: InputDecoration(
labelText: 'Host base',
hintText: 'http://localhost:8000',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.dns_outlined),
suffixIcon: IconButton(
icon: const Icon(Icons.save_outlined),
tooltip: 'Guardar host',
onPressed: _saveHost,
),
),
keyboardType: TextInputType.url,
autocorrect: false,
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _saveHost,
icon: const Icon(Icons.save),
label: const Text('Guardar host'),
),
),
if (_savedHost != null) ...[
const SizedBox(height: 6),
Text(
'Conectado a: $_savedHost',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.secondary,
),
),
],
const SizedBox(height: 32),
const Divider(),
const SizedBox(height: 16),
Text('Autenticación', style: theme.textTheme.titleLarge),
const SizedBox(height: 12),
if (_isLoggedIn) ...[
Card(
color: theme.colorScheme.primaryContainer,
child: ListTile(
leading: CircleAvatar(
backgroundColor: theme.colorScheme.primary,
child: const Icon(Icons.person, color: Colors.white),
),
title: Text(_userName ?? 'Usuario autenticado'),
subtitle: const Text('Sesión activa'),
trailing: IconButton(
icon: const Icon(Icons.logout),
tooltip: 'Cerrar sesión',
onPressed: _loading ? null : _logout,
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _loading ? null : _logout,
icon: const Icon(Icons.logout),
label: const Text('Cerrar sesión'),
),
),
] else ...[
Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Correo electrónico',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email_outlined),
),
keyboardType: TextInputType.emailAddress,
autocorrect: false,
validator: (v) =>
(v == null || v.isEmpty) ? 'Campo requerido' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Contraseña',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(_obscurePassword
? Icons.visibility_off
: Icons.visibility),
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword),
),
),
validator: (v) =>
(v == null || v.isEmpty) ? 'Campo requerido' : null,
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _loading ? null : _login,
icon: _loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.login),
label: const Text('Iniciar sesión'),
),
),
],
),
),
],
],
),
),
);
}
}
Puntos clave:
- onLoginSuccess es un callback que recibe el MainShell para navegar automáticamente a la pestaña de tareas tras iniciar sesión.
- _fetchMe() verifica que el token almacenado siga siendo válido al iniciar la pantalla.
- El bloque catch (_) en _logout garantiza que el token local se borre aunque el servidor falle.
- El if (mounted) antes de llamar a setState o ScaffoldMessenger evita errores cuando el widget ya no está en el árbol.
lib/screens/tasks_screen.dart
Lista principal de tareas con barra de búsqueda, chips de filtro por estado y FloatingActionButton para crear.
import 'package:flutter/material.dart';
import '../models/task.dart';
import '../services/api_service.dart';
import '../services/storage_service.dart';
import 'task_detail_screen.dart';
import 'task_form_screen.dart';
class TasksScreen extends StatefulWidget {
const TasksScreen({super.key});
@override
State<TasksScreen> createState() => _TasksScreenState();
}
class _TasksScreenState extends State<TasksScreen> {
List<Task> _tasks = [];
bool _loading = false;
String? _error;
final _searchController = TextEditingController();
String _filterStatus = 'todas';
bool _isSearching = false;
static const _statusOptions = ['todas', 'nueva', 'en_proceso', 'completada'];
static const _statusLabels = {
'todas': 'Todas',
'nueva': 'Nueva',
'en_proceso': 'En proceso',
'completada': 'Completada',
};
static const _statusColors = {
'nueva': Colors.blue,
'en_proceso': Colors.orange,
'completada': Colors.green,
};
@override
void initState() {
super.initState();
_loadTasks();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<bool> _checkAuth() async {
final token = await StorageService.getToken();
return token != null;
}
Future<void> _loadTasks() async {
if (!await _checkAuth()) {
setState(() {
_error = 'No hay sesión activa. Ve a Configuración para iniciar sesión.';
_loading = false;
});
return;
}
setState(() {
_loading = true;
_error = null;
});
try {
final data = await ApiService.getTasks();
setState(() {
_tasks = data.map((e) => Task.fromJson(e)).toList();
_loading = false;
_isSearching = false;
_searchController.clear();
_filterStatus = 'todas';
});
} catch (e) {
setState(() {
_error = e.toString();
_loading = false;
});
}
}
Future<void> _search() async {
final q = _searchController.text.trim();
final status = _filterStatus == 'todas' ? null : _filterStatus;
if (q.isEmpty && status == null) {
return _loadTasks();
}
setState(() {
_loading = true;
_error = null;
_isSearching = true;
});
try {
final data = await ApiService.searchTasks(q: q, status: status);
setState(() {
_tasks = data.map((e) => Task.fromJson(e)).toList();
_loading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_loading = false;
});
}
}
Future<void> _openCreate() async {
final created = await Navigator.of(context).push<Task>(
MaterialPageRoute(builder: (_) => const TaskFormScreen()),
);
if (created != null) {
setState(() => _tasks.insert(0, created));
}
}
Future<void> _openDetail(Task task) async {
final result = await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => TaskDetailScreen(task: task)),
);
if (result == 'deleted') {
setState(() => _tasks.removeWhere((t) => t.id == task.id));
} else if (result is Task) {
setState(() {
final idx = _tasks.indexWhere((t) => t.id == result.id);
if (idx != -1) _tasks[idx] = result;
});
}
}
Widget _buildStatusChip(String status) {
final color = _statusColors[status] ?? Colors.grey;
final label = _statusLabels[status] ?? status;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withAlpha(30),
border: Border.all(color: color),
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
style: TextStyle(
color: color, fontSize: 11, fontWeight: FontWeight.bold),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Mis Tareas'),
backgroundColor: theme.colorScheme.inversePrimary,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Recargar',
onPressed: _loading ? null : _loadTasks,
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Buscar tareas...',
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
isDense: true,
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_loadTasks();
},
)
: null,
),
onSubmitted: (_) => _search(),
onChanged: (_) => setState(() {}),
),
),
const SizedBox(width: 8),
IconButton.filled(
icon: const Icon(Icons.search),
onPressed: _loading ? null : _search,
tooltip: 'Buscar',
),
],
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: _statusOptions.map((s) {
final selected = _filterStatus == s;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(_statusLabels[s] ?? s),
selected: selected,
onSelected: (v) {
setState(() => _filterStatus = s);
_search();
},
),
);
}).toList(),
),
),
if (_isSearching)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'Mostrando ${_tasks.length} resultado(s)',
style: theme.textTheme.bodySmall,
),
),
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline,
size: 48, color: Colors.red),
const SizedBox(height: 12),
Text(_error!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.red)),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _loadTasks,
icon: const Icon(Icons.refresh),
label: const Text('Reintentar'),
),
],
),
),
)
: _tasks.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.check_box_outline_blank,
size: 60, color: Colors.grey),
const SizedBox(height: 12),
const Text('No hay tareas',
style: TextStyle(
fontSize: 18, color: Colors.grey)),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _openCreate,
icon: const Icon(Icons.add),
label: const Text('Nueva tarea'),
),
],
),
)
: RefreshIndicator(
onRefresh: _loadTasks,
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _tasks.length,
separatorBuilder: (_, __) =>
const SizedBox(height: 8),
itemBuilder: (ctx, i) {
final task = _tasks[i];
return Card(
elevation: 2,
child: ListTile(
title: Text(
task.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
task.body,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13),
),
const SizedBox(height: 4),
_buildStatusChip(task.status),
],
),
isThreeLine: true,
trailing: const Icon(Icons.chevron_right),
onTap: () => _openDetail(task),
),
);
},
),
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _openCreate,
icon: const Icon(Icons.add),
label: const Text('Nueva tarea'),
),
);
}
}
Puntos clave:
- El estado de la pantalla tiene tres variantes: cargando, error y datos (incluyendo lista vacía), cada uno con su UI específica.
- _openDetail procesa el valor de retorno del Navigator: si es 'deleted' elimina localmente, si es un Task actualiza el elemento en la lista.
- RefreshIndicator permite recargar con pull-to-refresh.
- Los FilterChip de estado disparan _search() automáticamente al seleccionarse.
lib/screens/task_detail_screen.dart
Vista de detalle de una tarea con opciones para editar, eliminar y cambiar de estado.
import 'package:flutter/material.dart';
import '../models/task.dart';
import '../services/api_service.dart';
import 'task_form_screen.dart';
class TaskDetailScreen extends StatefulWidget {
final Task task;
const TaskDetailScreen({super.key, required this.task});
@override
State<TaskDetailScreen> createState() => _TaskDetailScreenState();
}
class _TaskDetailScreenState extends State<TaskDetailScreen> {
late Task _task;
bool _loading = false;
static const _statuses = ['nueva', 'en_proceso', 'completada'];
static const _statusLabels = {
'nueva': 'Nueva',
'en_proceso': 'En proceso',
'completada': 'Completada',
};
static const _statusColors = {
'nueva': Colors.blue,
'en_proceso': Colors.orange,
'completada': Colors.green,
};
@override
void initState() {
super.initState();
_task = widget.task;
}
Future<void> _changeStatus(String newStatus) async {
if (newStatus == _task.status) return;
setState(() => _loading = true);
try {
final data = await ApiService.updateTaskStatus(_task.id, newStatus);
setState(() {
_task = Task.fromJson(data);
_loading = false;
});
} catch (e) {
setState(() => _loading = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString()), backgroundColor: Colors.red),
);
}
}
}
Future<void> _edit() async {
final updated = await Navigator.of(context).push<Task>(
MaterialPageRoute(builder: (_) => TaskFormScreen(task: _task)),
);
if (updated != null) setState(() => _task = updated);
}
Future<void> _delete() async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Eliminar tarea'),
content: const Text('¿Estás seguro de que quieres eliminar esta tarea?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Eliminar'),
),
],
),
);
if (confirm != true) return;
setState(() => _loading = true);
try {
await ApiService.deleteTask(_task.id);
if (mounted) Navigator.of(context).pop('deleted');
} catch (e) {
setState(() => _loading = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString()), backgroundColor: Colors.red),
);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final statusColor = _statusColors[_task.status] ?? Colors.grey;
return Scaffold(
appBar: AppBar(
title: const Text('Detalle de tarea'),
backgroundColor: theme.colorScheme.inversePrimary,
actions: [
IconButton(
icon: const Icon(Icons.edit_outlined),
tooltip: 'Editar',
onPressed: _loading ? null : _edit,
),
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Eliminar',
onPressed: _loading ? null : _delete,
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_task.title, style: theme.textTheme.headlineSmall),
const SizedBox(height: 8),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: statusColor.withAlpha(30),
border: Border.all(color: statusColor),
borderRadius: BorderRadius.circular(20),
),
child: Text(
_statusLabels[_task.status] ?? _task.status,
style: TextStyle(
color: statusColor, fontWeight: FontWeight.bold),
),
),
],
),
const SizedBox(height: 16),
Text(_task.body, style: theme.textTheme.bodyLarge),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 12),
Text('Cambiar estado', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: _statuses.map((s) {
final color = _statusColors[s] ?? Colors.grey;
final selected = s == _task.status;
return ChoiceChip(
label: Text(_statusLabels[s] ?? s),
selected: selected,
selectedColor: color.withAlpha(50),
labelStyle: TextStyle(
color: selected ? color : null,
fontWeight: selected ? FontWeight.bold : null,
),
onSelected: (_) => _changeStatus(s),
);
}).toList(),
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 8),
Text(
'Creada: ${_task.createdAt.toLocal().toString().split('.')[0]}',
style: theme.textTheme.bodySmall,
),
Text(
'Actualizada: ${_task.updatedAt.toLocal().toString().split('.')[0]}',
style: theme.textTheme.bodySmall,
),
],
),
),
);
}
}
Puntos clave:
- ChoiceChip muestra visualmente el estado actual y permite cambiarlo con un toque.
- Al confirmar la eliminación con un AlertDialog, se retorna 'deleted' al hacer Navigator.pop, lo que la pantalla anterior interpreta para eliminar la tarea de su lista.
- La tarea se actualiza localmente con Task.fromJson(data) usando la respuesta del servidor al cambiar el estado.
lib/screens/task_form_screen.dart
Formulario reutilizable para crear y editar tareas.
import 'package:flutter/material.dart';
import '../models/task.dart';
import '../services/api_service.dart';
class TaskFormScreen extends StatefulWidget {
final Task? task;
const TaskFormScreen({super.key, this.task});
@override
State<TaskFormScreen> createState() => _TaskFormScreenState();
}
class _TaskFormScreenState extends State<TaskFormScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _titleController;
late final TextEditingController _bodyController;
bool _loading = false;
bool get _isEditing => widget.task != null;
@override
void initState() {
super.initState();
_titleController = TextEditingController(text: widget.task?.title ?? '');
_bodyController = TextEditingController(text: widget.task?.body ?? '');
}
@override
void dispose() {
_titleController.dispose();
_bodyController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _loading = true);
try {
final title = _titleController.text.trim();
final body = _bodyController.text.trim();
Map<String, dynamic> result;
if (_isEditing) {
result = await ApiService.updateTask(widget.task!.id,
title: title, body: body);
} else {
result = await ApiService.createTask(title: title, body: body);
}
if (mounted) Navigator.of(context).pop(Task.fromJson(result));
} catch (e) {
setState(() => _loading = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString()), backgroundColor: Colors.red),
);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Text(_isEditing ? 'Editar tarea' : 'Nueva tarea'),
backgroundColor: theme.colorScheme.inversePrimary,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Título',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.title),
),
validator: (v) =>
(v == null || v.isEmpty) ? 'Campo requerido' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _bodyController,
decoration: const InputDecoration(
labelText: 'Descripción',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.notes),
alignLabelWithHint: true,
),
maxLines: 5,
validator: (v) =>
(v == null || v.isEmpty) ? 'Campo requerido' : null,
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _loading ? null : _submit,
icon: _loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: Icon(_isEditing ? Icons.save : Icons.add),
label: Text(_isEditing ? 'Guardar cambios' : 'Crear tarea'),
),
),
],
),
),
),
);
}
}
Puntos clave:
- El getter _isEditing determina el comportamiento del formulario en función de si se recibió una tarea como parámetro.
- Al enviar, retorna un objeto Task deserializado de la respuesta del API, garantizando que la UI use los datos confirmados por el servidor (ID, fechas, etc.).
- GlobalKey<FormState> permite invocar validate() programáticamente.
Paso 7 — Crear el punto de entrada main.dart
import 'package:flutter/material.dart';
import 'screens/tasks_screen.dart';
import 'screens/settings_screen.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Task Manager',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const MainShell(),
);
}
}
class MainShell extends StatefulWidget {
const MainShell({super.key});
@override
State<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
int _currentIndex = 0;
final _tasksKey = GlobalKey<State>();
void _onLoginSuccess() {
setState(() => _currentIndex = 0);
}
@override
Widget build(BuildContext context) {
final screens = [
TasksScreen(key: _tasksKey),
SettingsScreen(onLoginSuccess: _onLoginSuccess),
];
return Scaffold(
body: IndexedStack(
index: _currentIndex,
children: screens,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (i) => setState(() => _currentIndex = i),
destinations: const [
NavigationDestination(
icon: Icon(Icons.checklist_outlined),
selectedIcon: Icon(Icons.checklist),
label: 'Tareas',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Configuración',
),
],
),
);
}
}
Puntos clave:
- IndexedStack mantiene el estado de ambas pantallas aunque el usuario cambie de pestaña (la lista de tareas no se recarga al volver).
- _onLoginSuccess es el callback que SettingsScreen invoca para navegar a la pestaña de tareas automáticamente tras el login.
- useMaterial3: true activa los componentes y estilos de Material Design 3.
Paso 8 — Ejecutar la aplicación
# Verificar dispositivos disponibles
flutter devices
# Ejecutar en el dispositivo/emulador seleccionado
flutter run
# O específicamente en Android/iOS/Web:
flutter run -d android
flutter run -d chrome
Flujo de uso de la aplicación
Inicio
└─► MainShell (NavigationBar: Tareas | Configuración)
│
├─► [Configuración]
│ ├── Ingresar host del API → StorageService.saveHost()
│ ├── Login → ApiService.login() → StorageService.saveToken()
│ └── Logout → ApiService.logout() → StorageService.clearToken()
│
└─► [Tareas]
├── Cargar lista → ApiService.getTasks()
├── Buscar / Filtrar → ApiService.searchTasks()
├── Nueva tarea → TaskFormScreen (crear) → ApiService.createTask()
└── Toque en tarea → TaskDetailScreen
├── Cambiar estado → ApiService.updateTaskStatus()
├── Editar → TaskFormScreen (editar) → ApiService.updateTask()
└── Eliminar → ApiService.deleteTask()
Conceptos de Dart/Flutter aplicados
| Concepto | Dónde se aplica |
|---|---|
async / await |
Todas las llamadas HTTP en ApiService y en los métodos de las pantallas |
Future<T> |
Tipo de retorno de todos los métodos asíncronos del servicio |
factory constructor |
Task.fromJson y User.fromJson para deserializar JSON |
StatefulWidget + setState |
Gestión de estado local en todas las pantallas |
TextEditingController |
Campos de texto en formularios y barra de búsqueda |
GlobalKey<FormState> |
Validación programática de formularios |
Navigator.push / .pop |
Navegación entre pantallas con paso de datos de retorno |
IndexedStack |
Shell de navegación que preserva el estado de las pestañas |
if en colecciones |
Header Authorization condicional en _authHeaders() |
copyWith |
Actualización inmutable del modelo Task |
Operador ?. y ?? |
Acceso seguro a nullable y valores por defecto |
Vista previa de la aplicación
Capturas de pantalla
| Pantalla | Vista |
|---|---|
| Lista de tareas | ![]() |
| Detalle de tarea | ![]() |
| Crear / Editar tarea | ![]() |
| Configuración | ![]() |
Actividades adicionales
Las siguientes mejoras deben implementarse sobre la aplicación base ya funcional. Cada una es independiente de las demás.
Actividad 1 — Ícono personalizado de la app
Reemplazar el ícono predeterminado de Flutter por un ícono propio usando el paquete flutter_launcher_icons.
Pasos:
- Agregar la dependencia en
pubspec.yaml:
dev_dependencies:
flutter_launcher_icons: ^0.14.3
- Añadir la configuración del ícono en el mismo
pubspec.yaml:
flutter_launcher_icons:
android: true
ios: true
image_path: "assets/icon/app_icon.png"
-
Crear la carpeta
assets/icon/en la raíz del proyecto y colocar ahí una imagen PNG cuadrada de al menos 1024×1024 px. -
Ejecutar el generador:
dart run flutter_launcher_icons
- Verificar el resultado ejecutando la app en un emulador o dispositivo físico.
Actividad 2 — Indicador de color por estado en la lista de tareas
Agregar una franja de color de 4 px en el borde izquierdo de cada tarjeta de tarea en TasksScreen, según el estado:
| Estado | Color |
|---|---|
nueva |
Azul |
en_proceso |
Naranja |
completada |
Verde |
Pista: envolver el widget de cada elemento de la lista en un Container con decoration: BoxDecoration(border: Border(left: BorderSide(color: ..., width: 4))) o usar el parámetro shape de Card junto con un ClipRect.
Lo que se practica: composición de widgets, uso de constantes de color y renderizado condicional basado en datos del modelo.
Actividad 3 — Contador de tareas por estado
Mostrar debajo de los chips de filtro en TasksScreen un resumen con la cantidad de tareas por estado, calculado localmente sobre la lista cargada, por ejemplo:
3 nueva · 2 en proceso · 1 completada
Pista: usar .where((t) => t.status == 'nueva').length sobre la lista _tasks dentro del método build. El contador debe actualizarse automáticamente al recargar, crear, editar o eliminar tareas porque ya se llama a setState en esos casos.
Lo que se practica: manipulación de listas con métodos de colección de Dart, renderizado reactivo con setState y composición de texto en la UI.



