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 StatefulWidget de forma directa (sin gestor de estado externo) para mantener la práctica accesible para estudiantes que comienzan.
  • El ApiService centraliza toda la comunicación HTTP con la API y lanza excepciones tipadas (ApiException) cuando el servidor responde con código ≥ 400.
  • El StorageService abstrae completamente el acceso a SharedPreferences, siguiendo el principio de responsabilidad única.
  • La navegación usa Navigator.push retornando objetos (Task o 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 Lista de tareas
Detalle de tarea Lista de tareas
Crear / Editar tarea Lista de tareas
Configuración Lista de tareas

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:

  1. Agregar la dependencia en pubspec.yaml:
dev_dependencies:
  flutter_launcher_icons: ^0.14.3
  1. 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"
  1. Crear la carpeta assets/icon/ en la raíz del proyecto y colocar ahí una imagen PNG cuadrada de al menos 1024×1024 px.

  2. Ejecutar el generador:

dart run flutter_launcher_icons
  1. 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.