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 |
Endpoints del API utilizados
| Método | Endpoint | Descripción |
|---|---|---|
POST |
/api/auth/register |
Registro de usuario |
POST |
/api/auth/login |
Login, retorna access_token |
POST |
/api/auth/logout |
Cerrar sesión |
GET |
/api/auth/me |
Usuario autenticado |
POST |
/api/auth/refresh |
Refrescar token |
GET |
/api/tasks |
Listar tareas del usuario |
POST |
/api/tasks |
Crear tarea |
GET |
/api/tasks/{id} |
Obtener tarea por ID |
PUT |
/api/tasks/{id} |
Actualizar título y cuerpo |
PATCH |
/api/tasks/{id}/status |
Cambiar estado |
DELETE |
/api/tasks/{id} |
Eliminar tarea |
GET |
/api/tasks/search?q=&status= |
Buscar tareas |
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 |