Análisis y Desarrollo de Software
Taller Individual · Flutter · Proyecto "Café Místico"
Fase 0 · Requisitos Fase 1 · Proyecto y assets Fase 2 · Modelo y backend Fase 3 · Componentes Fase 4 · Pantallas ✅ Checklist
📱 TALLER INDIVIDUAL · ADSO · FASE 3 DE 5

Componentes
reutilizables

TopBar, SearchBarWithFilter, CategorySelector y ProductCard: cada uno construido y probado por separado antes de integrarlos en la pantalla del menú.

📅 Sesión del 27 de julio de 2026
☕ ecommer_app
🔒 Código protegido, solo lectura
🔒
Código de solo lectura: no se puede seleccionar, copiar ni pegar (tampoco desde el inspector del navegador). Debes escribirlo tú mismo, línea por línea, en tu editor.
En cada paso encontrarás: 💻 Código 🧪 Prueba 🔒 Sin copiar
3
FASE 3
Componentes reutilizables (widgets/)
PASO 8
Encabezado (widgets/top_bar.dart)
💻 Código
Imports y estructura del widget (fila con saludo a la izquierda y botones a la derecha):
dart🔒 import 'package:flutter/material.dart'; class TopBar extends StatelessWidget { const TopBar({super.key}); @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( '¡Bienvenido! 👋', style: TextStyle( color: Colors.white.withValues(alpha: 0.6), fontSize: 20, ), ), const SizedBox(height: 4), const Text( 'Encuentra tu café', style: TextStyle( color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold, ), ), ], ), Row( children: [ _buildIconButton(icon: Icons.person_outline_rounded, onTap: () {}), const SizedBox(width: 10), _buildIconButton(icon: Icons.notifications_none_rounded, onTap: () {}), ], ), ], ); }
Y el botón circular reutilizable, dentro de la misma clase:
dart🔒 Widget _buildIconButton({ required IconData icon, required VoidCallback onTap, }) { return Material( color: Colors.transparent, child: InkWell( onTap: onTap, customBorder: const CircleBorder(), child: Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: const Color(0xFF26221F), shape: BoxShape.circle, border: Border.all(color: Colors.white.withValues(alpha: 0.08)), ), child: Icon(icon, color: Colors.white, size: 22), ), ), ); } }
🧪 Prueba este componente

En main.dart, reemplaza temporalmente el body por Padding(padding: const EdgeInsets.all(20), child: const TopBar()). Corre la app: debes ver el saludo a la izquierda y los dos íconos circulares a la derecha, en la misma fila.

PASO 9
Buscador (widgets/search_bar_with_filter.dart)
💻 Código
El campo de texto expandido:
dart🔒 import 'package:flutter/material.dart'; class SearchBarWithFilter extends StatelessWidget { final TextEditingController? controller; final ValueChanged<String>? onChanged; final ValueChanged<String>? onSubmitted; final VoidCallback? onFilterTap; const SearchBarWithFilter({ super.key, this.controller, this.onChanged, this.onSubmitted, this.onFilterTap, }); @override Widget build(BuildContext context) { return Row( children: [ Expanded( child: Container( height: 52, decoration: BoxDecoration( color: const Color(0xFF26221F), borderRadius: BorderRadius.circular(16), ), child: TextField( controller: controller, onChanged: onChanged, onSubmitted: onSubmitted, style: const TextStyle(color: Colors.white, fontSize: 15), textAlignVertical: TextAlignVertical.center, decoration: InputDecoration( hintText: 'Buscar café, postres...', hintStyle: TextStyle(color: Colors.white.withValues(alpha: 0.4), fontSize: 15), prefixIcon: Icon(Icons.search_rounded, color: Colors.white.withValues(alpha: 0.4)), border: InputBorder.none, isDense: true, ), ), ), ),
Y el botón de filtro, cerrando la fila:
dart🔒 const SizedBox(width: 12), Material( color: const Color(0xFFC88D51), borderRadius: BorderRadius.circular(16), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onFilterTap, child: const SizedBox( height: 52, width: 52, child: Icon(Icons.tune_rounded, color: Colors.white, size: 22), ), ), ), ], ); } }
🧪 Prueba este componente

Agrega const SizedBox(height: 16) y const SearchBarWithFilter() debajo del TopBar() de la prueba anterior. Corre de nuevo: debes ver el campo de búsqueda y, a la derecha, el botón naranja con el ícono de filtro.

PASO 10
Selector de categorías (widgets/category_selector.dart)
💻 Código
Este widget necesita recordar cuál categoría está seleccionada, por eso es StatefulWidget:
dart🔒 import 'package:flutter/material.dart'; class CategorySelector extends StatefulWidget { final ValueChanged<String>? onCategorySelected; const CategorySelector({super.key, this.onCategorySelected}); @override State<CategorySelector> createState() => _CategorySelectorState(); } class _CategorySelectorState extends State<CategorySelector> { int _selectedIndex = 0; final List<String> _categories = ['Todos', 'Espresso', 'Capuchino'];
Y el build que dibuja cada botón con List.generate:
dart🔒 @override Widget build(BuildContext context) { return SizedBox( height: 40, child: Row( children: List.generate(_categories.length, (index) { final isSelected = _selectedIndex == index; return Expanded( child: Padding( padding: EdgeInsets.only(right: index == _categories.length - 1 ? 0 : 12), child: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(20), child: InkWell( borderRadius: BorderRadius.circular(20), onTap: () { setState(() { _selectedIndex = index; }); if (widget.onCategorySelected != null) { widget.onCategorySelected!(_categories[index]); } }, child: AnimatedContainer( duration: const Duration(milliseconds: 200), alignment: Alignment.center, decoration: BoxDecoration( color: isSelected ? const Color(0xFFC88D51) : const Color(0xFF26221F), borderRadius: BorderRadius.circular(20), ), child: Text( _categories[index], textAlign: TextAlign.center, style: TextStyle( color: isSelected ? Colors.white : Colors.white.withValues(alpha: 0.6), fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, fontSize: 13, ), ), ), ), ), ), ); }), ), ); } }
🧪 Prueba este componente

Agrega const CategorySelector() debajo del buscador. Corre la app y toca cada categoría: el fondo debe volverse naranja solo en la que tocaste.

PASO 11
Tarjeta de producto (widgets/product_card.dart)
💻 Código
Estructura general y la imagen del producto:
dart🔒 import 'package:flutter/material.dart'; import '../models/product.dart'; class ProductCard extends StatelessWidget { final Product product; final VoidCallback? onTap; final VoidCallback? onAddTap; const ProductCard({super.key, required this.product, this.onTap, this.onAddTap}); @override Widget build(BuildContext context) { return Material( color: const Color(0xFF26221F), borderRadius: BorderRadius.circular(20), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.all(12.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.circular(16), child: Container( height: 120, width: double.infinity, color: const Color(0xFF1F1B18), child: product.imageUrl.isNotEmpty && product.imageUrl.startsWith('http') ? Image.network( product.imageUrl, fit: BoxFit.cover, loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; return const Center( child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFFC88D51)), ); }, errorBuilder: (context, error, stackTrace) => const Center( child: Icon(Icons.sell_rounded, color: Color(0xFFC88D51), size: 40), ), ) : const Center( child: Icon(Icons.sell_rounded, color: Color(0xFFC88D51), size: 40), ), ), ),
Y el título, la descripción, el precio y el botón de agregar:
dart🔒 const SizedBox(height: 10), Text( product.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15), ), const SizedBox(height: 4), Text( product.description, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 11), ), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '\$${product.price.toStringAsFixed(2)}', style: const TextStyle(color: Color(0xFFF5E6D3), fontWeight: FontWeight.bold, fontSize: 16), ), Material( color: const Color(0xFFC88D51), borderRadius: BorderRadius.circular(10), child: InkWell( borderRadius: BorderRadius.circular(10), onTap: onAddTap, child: const Padding( padding: EdgeInsets.all(6.0), child: Icon(Icons.add_rounded, color: Colors.white, size: 20), ), ), ), ], ), ], ), ), ), ); } }
🧪 Prueba este componente

Sin backend todavía, crea final demo = Product(id: '1', title: 'Espresso Místico', description: 'Sabor intenso', price: 3.5, imageUrl: ''); y colócalo en SizedBox(width: 180, height: 240, child: ProductCard(product: demo)). Debes ver la tarjeta con el ícono de café, el nombre, la descripción y el precio "$3.50".

← Fase 2 · Modelo y backend Siguiente: Fase 4 · Pantallas →