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

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