Implementación de una Página de Detalle de Producto en Flutter para E-Commerce

Este artículo detalla la implementación de una página de detalle de producto completa para una aplicación de comercio electrónico construida con Flutter. El enfoque se centra en resolver los desafíos de desarrollo comunes, como la integración de datos dinámicos y la interacción con componentes de UI, para crear una experiencia de usuario coherente y funcional.

Contexto del Proyecto y Flujo de Trabajo

El desarrollo se basa en una aplicación Flutter existente con una arquitectura de navegación inferior. El objetivo es integrar una nueva pantalla de detalle de producto, que sea accesible desde una lista o un carrusel de imágenes, mostrando toda la información relevante del artículo de forma organizada.

El flujo de trabajo seguido fue: Diseño de la Arquitectura de Datos → Definición de Contratos de API → Construcción de la Interfaz de Usuario → Integración de Navegación y Manejo de Estado.

Arquitectura de Datos

Se diseñaron modelos de datos para estructurar la información del producto recibida de una API. Estos modelos implementan fábricas para la deserialización desde JSON, garantizando robustez frente a datos nulos o faltantes.

// Modelo para un parámetro individual del producto
class ParametroProducto {
  final String etiqueta;
  final String contenido;

  ParametroProducto({required this.etiqueta, required this.contenido});

  factory ParametroProducto.desdeJson(List<dynamic> json) {
    return ParametroProducto(
      etiqueta: json[0] ?? 'Sin etiqueta',
      contenido: json[1] ?? 'Sin información',
    );
  }
}

// Modelo para una característica o beneficio destacado
class CaracteristicaProducto {
  final String resumen;
  final String descripcionCompleta;

  CaracteristicaProducto({required this.resumen, required this.descripcionCompleta});

  factory CaracteristicaProducto.desdeJson(Map<string dynamic=""> json) {
    return CaracteristicaProducto(
      resumen: json['resumen'] ?? '',
      descripcionCompleta: json['detalle'] ?? '',
    );
  }
}

// Modelo principal que agrega toda la información del producto
class DetalleProducto {
  final String identificador;
  final String titulo;
  final String subtitulo;
  final String imagenPrincipal;
  final String precioActual;
  final String precioAnterior;
  final String colorTema;
  final String descripcion;
  final List<string> galeriaImagenes;
  final List<parametroproducto> parametros;
  final List<caracteristicaproducto> caracteristicas;

  DetalleProducto({
    required this.identificador,
    required this.titulo,
    required this.subtitulo,
    required this.imagenPrincipal,
    required this.precioActual,
    required this.precioAnterior,
    required this.colorTema,
    required this.descripcion,
    required this.galeriaImagenes,
    required this.parametros,
    required this.caracteristicas,
  });

  factory DetalleProducto.desdeJson(Map<string dynamic=""> jsonData) {
    final List<parametroproducto> parametrosLista = (jsonData['especificaciones'] as List?)
        ?.map((item) => ParametroProducto.desdeJson(item))
        .toList() ?? [];
    final List<caracteristicaproducto> caracteristicasLista = (jsonData['puntos_venta'] as List?)
        ?.map((item) => CaracteristicaProducto.desdeJson(item))
        .toList() ?? [];
    final List<string> galeria = (jsonData['imagenes_detalle'] as List?)
        ?.map((item) => item.toString())
        .toList() ?? [];

    return DetalleProducto(
      identificador: jsonData['sku'] ?? '',
      titulo: jsonData['nombre'] ?? '',
      subtitulo: jsonData['nombre_alternativo'] ?? '',
      imagenPrincipal: jsonData['url_imagen'] ?? '',
      precioActual: jsonData['costo'] ?? '0.00',
      precioAnterior: jsonData['costo_original'] ?? '0.00',
      colorTema: jsonData['color_principal'] ?? '#000000',
      descripcion: jsonData['ficha_tecnica'] ?? '',
      galeriaImagenes: galeria,
      parametros: parametrosLista,
      caracteristicas: caracteristicasLista,
    );
  }
}</string></caracteristicaproducto></parametroproducto></string></caracteristicaproducto></parametroproducto></string></string></dynamic>

Capa de Acceso a Datos y Página Principal

Se creó una capa de servicio para encapsular las llamadas HTTP, separando la lógica de negocio de la interfaz de usuario.

import 'package:dio/dio.dart';
import 'ruta/al/modelo_detalle.dart'; // Importar el modelo DetalleProducto

class ServicioProducto {
  final Dio _clienteHttp;

  ServicioProducto(this._clienteHttp);

  Future<DetalleProducto> obtenerDetalle(String sku) async {
    final respuesta = await _clienteHttp.get('/v1/productos/$sku');
    return DetalleProducto.desdeJson(respuesta.data);
  }

  Future<List<String>> obtenerIdsParaCarrusel() async {
    final respuesta = await _clienteHttp.get('/v1/destacados');
    return (respuesta.data['ids'] as List).cast<String>();
  }
}

La página de detalle consume estos datos y los presenta en una interfaz desplazable. Utiliza un FutureBuilder para manejar los estados de carga y error de la petición asíncrona.

import 'package:flutter/material.dart';
import 'ruta/al/servicio_producto.dart';
import 'ruta/al/modelo_detalle.dart';

class PantallaDetalleProducto extends StatefulWidget {
  final String skuProducto;
  const PantallaDetalleProducto({Key? key, required this.skuProducto}) : super(key: key);

  @override
  State<PantallaDetalleProducto> createState() => _PantallaDetalleProductoState();
}

class _PantallaDetalleProductoState extends State<PantallaDetalleProducto> {
  late Future<DetalleProducto> _futuroDetalle;

  @override
  void initState() {
    super.initState();
    _futuroDetalle = ServicioProducto(Dio()).obtenerDetalle(widget.skuProducto);
  }

  Color _parsearColor(String hex) {
    if (hex.isEmpty || !hex.startsWith('#')) return Colors.blueGrey;
    final buffer = StringBuffer();
    if (hex.length == 6 || hex.length == 7) buffer.write('ff');
    buffer.write(hex.replaceFirst('#', ''));
    return Color(int.parse(buffer.toString(), radix: 16));
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<DetalleProducto>(
      future: _futuroDetalle,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const Scaffold(body: Center(child: CircularProgressIndicator()));
        }
        if (snapshot.hasError || !snapshot.hasData) {
          return Scaffold(body: Center(child: Text('No se pudo cargar el producto.')));
        }
        final producto = snapshot.data!;
        final colorTema = _parsearColor(producto.colorTema);

        return Scaffold(
          backgroundColor: Colors.white,
          appBar: AppBar(
            backgroundColor: colorTema,
            title: Text(producto.titulo),
            leading: IconButton(
              icon: const Icon(Icons.arrow_back_ios),
              onPressed: () => Navigator.of(context).pop(),
            ),
          ),
          body: CustomScrollView(
            slivers: [
              SliverToBoxAdapter(
                child: Image.network(producto.imagenPrincipal, fit: BoxFit.cover),
              ),
              SliverPadding(
                padding: const EdgeInsets.all(16.0),
                sliver: SliverList(
                  delegate: SliverChildListDelegate([
                    _construirEncabezado(producto, colorTema),
                    const Divider(),
                    _construirBeneficios(producto),
                    const Divider(),
                    _construirParametros(producto),
                  ]),
                ),
              ),
            ],
          ),
          bottomNavigationBar: _construirBarraAcciones(colorTema),
        );
      },
    );
  }

  Widget _construirEncabezado(DetalleProducto producto, Color colorTema) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(producto.titulo, style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: colorTema)),
        Text(producto.subtitulo, style: TextStyle(color: Colors.grey[600])),
        const SizedBox(height: 8),
        Row(
          children: [
            Text('\$${producto.precioActual}', style: const TextStyle(fontSize: 20, color: Colors.red, fontWeight: FontWeight.bold)),
            const SizedBox(width: 10),
            Text('\$${producto.precioAnterior}', style: const TextStyle(decoration: TextDecoration.lineThrough, color: Colors.grey)),
          ],
        ),
      ],
    );
  }

  // ... Métodos auxiliares para construir otras secciones (_construirBeneficios, _construirParametros, etc.)

  BottomAppBar _construirBarraAcciones(Color colorTema) {
    return BottomAppBar(
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
        child: Row(
          children: [
            Expanded(
              child: OutlinedButton.icon(
                onPressed: () { /* Lógica para contactar soporte */ },
                icon: const Icon(Icons.headset_mic_outlined),
                label: const Text('Asesor'),
              ),
            ),
            const SizedBox(width: 16),
            Expanded(
              child: ElevatedButton.icon(
                onPressed: () { /* Lógica para agregar al carrito */ },
                icon: const Icon(Icons.shopping_cart_outlined),
                label: const Text('Agregar'),
                style: ElevatedButton.styleFrom(backgroundColor: colorTema),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Integración con un Carrusel Interactivo

Para la navegación desde un carrusel, se modificó el componente existente para mapear cada imagen a un identificdaor único (SKU) y manejar la navegación al hacer clic.

import 'package:carousel_slider/carousel_slider.dart';
import 'package:flutter/material.dart';
import 'ruta/a/pantalla_detalle.dart'; // Importar la pantalla de detalle

class CarruselDestacados extends StatefulWidget {
  final List<String> urlsImagenes;
  final List<String> skusAsociados;

  const CarruselDestacados({
    Key? key,
    required this.urlsImagenes,
    required this.skusAsociados,
  }) : super(key: key);

  @override
  State<CarruselDestacados> createState() => _CarruselDestacadosState();
}

class _CarruselDestacadosState extends State<CarruselDestacados> {
  @override
  Widget build(BuildContext context) {
    return CarouselSlider.builder(
      itemCount: widget.urlsImagenes.length,
      itemBuilder: (context, itemIndex, pageViewIndex) {
        final url = widget.urlsImagenes[itemIndex];
        final sku = widget.skusAsociados[itemIndex]; // Obtener SKU correspondiente

        return GestureDetector(
          onTap: () {
            // Navegar a la pantalla de detalle pasando el SKU
            Navigator.push(
              context,
              MaterialPageRoute(
                builder: (_) => PantallaDetalleProducto(skuProducto: sku),
              ),
            );
          },
          child: ClipRRect(
            borderRadius: BorderRadius.circular(8.0),
            child: Image.network(
              url,
              fit: BoxFit.cover,
              width: double.infinity,
              errorBuilder: (context, error, stackTrace) => Container(
                color: Colors.grey[200],
                child: const Center(child: Icon(Icons.broken_image, color: Colors.grey)),
              ),
            ),
          ),
        );
      },
      options: CarouselOptions(
        autoPlay: true,
        enlargeCenterPage: true,
        aspectRatio: 2.0,
      ),
    );
  }
}

Consideraciones de Implementación y Solución de Problemas

1. Navegación y Paso de Parámetros

La clave para la navegación correcta es garantizar la correspondencia 1:1 entre el índice de la imagen del carrusel y el identificador del producto. Se debe validar que ambas listas tengan la misma longitud antes de renderizar el carrusel para evitar errores de rango.

2. Manejo de Recursos de Imagen

Las imágenes de red pueden fallar. Es esencial usar el parámetro errorBuilder en Image.network para mostrar un widget de respaldo (como un ícono o una imagen local) y evitar que la aplicación muestre un error rojo. Para una experiencia de usuario óptima, considere implementar una solución de caché de imágenes.

3. Aplicación de Temas Dinámicos

Los colores de tema dinámicos (como el color de la AppBar) requieren un análisis cuidadoso. Los valores hexadecimal deben convertirse a enteros de 32 bits que Flutter pueda interpretar. La función _parsearColor mostrada anteriormente maneja formatos comunes (#RRGGBB) y proporciona un color predeterminado en caso de error.

Estructura de Archivos Resultante

lib/
├── models/
│   └── producto_modelo.dart
├── services/
│   └── api_producto.dart
├── screens/
│   └── pantalla_detalle_producto.dart
└── widgets/
    └── carrusel_destacados.dart

Etiquetas: Flutter Dart desarrollo-movil e-commerce ui-ux

Publicado el 6-17 16:53