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 Fase 5 · Autenticación ✅ Checklist
📱 TALLER INDIVIDUAL · ADSO · FASE 5 DE 6

Autenticación
Login, Registro y Recuperar contraseña

Sesión guiada paso a paso: modelo de Usuario, servicios de sesión y autenticación, las pantallas de Login/Registro/Recuperar contraseña, y la conexión del ícono de persona del TopBar con el flujo de inicio de sesión — probando cada pieza antes de seguir con la siguiente.

☕ ecommer_app
📦 10 archivos
🔒 Código protegido, solo lectura
🎯 Objetivo de la sesión de hoy

Al finalizar esta sesión, el aprendiz estará en capacidad de conectar la app con el sistema de autenticación del backend: registrar usuarios, iniciar sesión, recuperar una contraseña olvidada, y mantener la sesión guardada en el dispositivo.

🔒
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
🔐
FASE 5
Login, Registro y Recuperar contraseña
PASO 1
Dependencia y modelo Usuario
🔒 Sin copiar
Agrega shared_preferences a tu pubspec.yaml (guarda la sesión en el dispositivo) y crea lib/models/usuario.dart, que tipa la respuesta del backend en vez de manejar Map suelto.
BASH · terminal 🔒
flutter pub add shared_preferences
DART · lib/models/usuario.dart 🔒 class Usuario { final String id; final String nombre; final String apellido; final String correo; final String telefono; final String rol; Usuario({ required this.id, required this.nombre, required this.apellido, required this.correo, required this.telefono, required this.rol, }); factory Usuario.fromJson(Map<String, dynamic> json) { return Usuario( id: json['_id'] ?? '', nombre: json['Nombre'] ?? '', apellido: json['Apellido'] ?? '', correo: json['Correo_Electronico'] ?? '', telefono: json['telefono'] ?? '', rol: json['rol'] ?? 'APRENDIZ', ); } Map<String, dynamic> toJson() { return { '_id': id, 'Nombre': nombre, 'Apellido': apellido, 'Correo_Electronico': correo, 'telefono': telefono, 'rol': rol, }; } String get nombreCompleto => '$nombre $apellido'; }
🧪 Prueba
  • Corre flutter pub get y confirma que no hay errores.
  • En cualquier archivo, prueba Usuario.fromJson({'Nombre': 'Ana', 'Apellido': 'Ruiz', ...}) y verifica con print() que nombreCompleto devuelva "Ana Ruiz".
PASO 2
SessionService — guarda el token en el dispositivo
🔒 Sin copiar
Servicio que guarda y lee el token JWT + el Usuario logueado usando shared_preferences, para que la sesión persista aunque se cierre la app.
DART · lib/services/session_service.dart 🔒 import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/usuario.dart'; /// Guarda el token JWT y los datos del usuario logueado en el dispositivo, /// para que la sesión persista aunque se cierre la app. class SessionService { static const _keyToken = 'auth_token'; static const _keyUsuario = 'auth_usuario'; static Future<void> guardarSesion(String token, Usuario usuario) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_keyToken, token); await prefs.setString(_keyUsuario, jsonEncode(usuario.toJson())); } static Future<String?> obtenerToken() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString(_keyToken); } static Future<Usuario?> obtenerUsuario() async { final prefs = await SharedPreferences.getInstance(); final data = prefs.getString(_keyUsuario); if (data == null) return null; return Usuario.fromJson(jsonDecode(data) as Map<String, dynamic>); } static Future<bool> haySesionActiva() async { final token = await obtenerToken(); return token != null; } static Future<void> cerrarSesion() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_keyToken); await prefs.remove(_keyUsuario); } }
🧪 Prueba
  • Después de implementar el Login (paso 5), cierra la app por completo y vuelve a abrirla.
  • Llama a SessionService.haySesionActiva() y confirma que devuelva true sin haber iniciado sesión de nuevo.
PASO 3
AuthService — login, registro, logout y recuperar contraseña
🔒 Sin copiar
Centraliza todas las llamadas HTTP de autenticación: login(), registrar(), logout(), y los dos métodos nuevos para recuperar contraseña: solicitarCodigo() y cambiarPassword().
DART · lib/services/auth_service.dart 🔒 import 'dart:convert'; import 'package:http/http.dart' as http; import 'api_config.dart'; import 'session_service.dart'; import '../models/usuario.dart'; class AuthService { // Inicia sesión, guarda el token + usuario en el dispositivo y devuelve // el Usuario ya tipado, listo para usar en la UI. static Future<Usuario> login({ required String correo, required String password, }) async { final response = await http.post( Uri.parse('${ApiConfig.baseUrl}/auth/login'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'Correo_Electronico': correo, 'passwords': password, }), ); final data = jsonDecode(response.body) as Map<String, dynamic>; if (response.statusCode == 200) { final usuario = Usuario.fromJson(data['usuario']); await SessionService.guardarSesion(data['token'], usuario); return usuario; } else { throw Exception(data['message'] ?? 'Error al iniciar sesión'); } } // Registra un usuario nuevo. El backend ya devuelve el token, así que // queda logueado automáticamente después de registrarse. static Future<Usuario> registrar({ required String nombre, required String apellido, required String telefono, required String correo, required String password, }) async { final response = await http.post( Uri.parse('${ApiConfig.baseUrl}/auth/registro'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'Nombre': nombre, 'Apellido': apellido, 'telefono': telefono, 'Correo_Electronico': correo, 'passwords': password, }), ); final data = jsonDecode(response.body) as Map<String, dynamic>; if (response.statusCode == 201) { final usuario = Usuario.fromJson(data['usuario']); if (data['token'] != null) { await SessionService.guardarSesion(data['token'], usuario); } return usuario; } else { throw Exception(data['message'] ?? 'Error al registrarse'); } } static Future<void> logout() async { await SessionService.cerrarSesion(); } // Pide que el backend genere un código de 6 dígitos y lo envíe al correo. static Future<void> solicitarCodigo({required String correo}) async { final response = await http.post( Uri.parse('${ApiConfig.baseUrl}/recuperar/solicitar-codigo'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({'Correo_Electronico': correo}), ); final data = jsonDecode(response.body) as Map<String, dynamic>; if (response.statusCode != 200) { throw Exception(data['message'] ?? 'Error al solicitar el código'); } } // Verifica el código y cambia la contraseña. static Future<void> cambiarPassword({ required String correo, required String codigo, required String nuevaPassword, }) async { final response = await http.post( Uri.parse('${ApiConfig.baseUrl}/recuperar/cambiar-password'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'Correo_Electronico': correo, 'codigo': codigo, 'nuevaPassword': nuevaPassword, }), ); final data = jsonDecode(response.body) as Map<String, dynamic>; if (response.statusCode != 200) { throw Exception(data['message'] ?? 'Error al cambiar la contraseña'); } } }
🧪 Prueba
  • Con el backend corriendo, prueba AuthService.login(correo: ..., password: ...) con un usuario válido y confirma que no lance excepción.
  • Prueba con una contraseña incorrecta y confirma que el catch reciba el mensaje de error del backend.
PASO 4
Backend — rutas de recuperar contraseña
🔒 Sin copiar
Este paso es de backend, no de Flutter. El controller Recuperar.js ya existe (con solicitarCodigo y cambiarPassword) — solo falta conectarlo a una ruta y montarla en el servidor.
JAVASCRIPT · routes/recuperarRoutes.js 🔒 import { Router } from "express"; import { solicitarCodigo, cambiarPassword } from "../controllers/Recuperar.js"; const router = Router(); router.post("/solicitar-codigo", solicitarCodigo); router.post("/cambiar-password", cambiarPassword); export default router;
JAVASCRIPT · server.js 🔒
import recuperarRoutes from "./routes/recuperarRoutes.js";
app.use("/api/recuperar", recuperarRoutes);
⚠️
Bug a revisar en Recuperar.js: dentro de cambiarPassword, la línea usuario.Password = nuevaPassword; no coincide con el campo real del modelo User (que se llama passwords, en minúscula). Como el nombre no coincide, la contraseña nunca se actualiza. Cámbiala por usuario.passwords = nuevaPassword;
🧪 Prueba
  • Con Postman o curl, manda un POST a /api/recuperar/solicitar-codigo con un correo real registrado y confirma que llegue el código al correo.
PASO 5
Pantalla de Login
🔒 Sin copiar
Formulario de correo y contraseña. _iniciarSesion() valida el formulario, llama a AuthService.login() y navega al Menu. Incluye el link "¿Olvidaste tu contraseña?".
DART · lib/pages/login.dart 🔒 import 'package:flutter/material.dart'; import 'package:ecommer_app/services/auth_service.dart'; import 'package:ecommer_app/pages/menu.dart'; import 'package:ecommer_app/pages/register.dart'; import 'package:ecommer_app/pages/forgot_password.dart'; class LoginPage extends StatefulWidget { const LoginPage({super.key}); @override State<LoginPage> createState() => _LoginPageState(); } class _LoginPageState extends State<LoginPage> { final _formKey = GlobalKey<FormState>(); final _correoController = TextEditingController(); final _passwordController = TextEditingController(); bool _cargando = false; bool _verPassword = false; @override void dispose() { _correoController.dispose(); _passwordController.dispose(); super.dispose(); } Future<void> _iniciarSesion() async { if (!_formKey.currentState!.validate()) return; setState(() => _cargando = true); try { await AuthService.login( correo: _correoController.text.trim(), password: _passwordController.text, ); if (!mounted) return; Navigator.pushReplacement( context, MaterialPageRoute(builder: (_) => const Menu()), ); } catch (error) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(error.toString().replaceFirst('Exception: ', '')), backgroundColor: Colors.redAccent, ), ); } finally { if (mounted) setState(() => _cargando = false); } } // Decoración reutilizable para los inputs InputDecoration _inputStyle(String label, String hint, IconData icon, {Widget? suffix}) { return InputDecoration( labelText: label, labelStyle: const TextStyle(color: Colors.white70, fontSize: 14), hintText: hint, hintStyle: const TextStyle(color: Colors.white30), prefixIcon: Icon(icon, color: Colors.white38), suffixIcon: suffix, filled: true, fillColor: const Color(0xFF26221F), border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), contentPadding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), ); } @override Widget build(BuildContext context) { const accentColor = Color(0xFFC88D51); return Scaffold( backgroundColor: const Color(0xFF181513), body: SafeArea( child: Center( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 30), child: Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Text( 'Café Místico', textAlign: TextAlign.center, style: TextStyle(fontSize: 34, fontWeight: FontWeight.bold, color: Color(0xFFF5E6D3), letterSpacing: 1.5), ), const SizedBox(height: 6), const Text( 'Inicia sesión para continuar', textAlign: TextAlign.center, style: TextStyle(color: Colors.white60, fontSize: 13), ), const SizedBox(height: 32), // Correo TextFormField( controller: _correoController, keyboardType: TextInputType.emailAddress, style: const TextStyle(color: Colors.white), decoration: _inputStyle('Correo electrónico', 'correo@ejemplo.com', Icons.email_outlined), validator: (v) => (v == null || !v.contains('@')) ? 'Ingresa un correo válido' : null, ), const SizedBox(height: 18), // Contraseña TextFormField( controller: _passwordController, obscureText: !_verPassword, style: const TextStyle(color: Colors.white), decoration: _inputStyle( 'Contraseña', '••••••••', Icons.lock_outline_rounded, suffix: IconButton( icon: Icon(_verPassword ? Icons.visibility_off_rounded : Icons.visibility_rounded, color: Colors.white38), onPressed: () => setState(() => _verPassword = !_verPassword), ), ), validator: (v) => (v == null || v.isEmpty) ? 'Ingresa tu contraseña' : null, ), const SizedBox(height: 8), // Olvidé mi contraseña Align( alignment: Alignment.centerRight, child: TextButton( onPressed: () => Navigator.push( context, MaterialPageRoute(builder: (_) => const ForgotPasswordPage()), ), child: const Text('¿Olvidaste tu contraseña?', style: TextStyle(color: Colors.white60, fontSize: 12)), ), ), const SizedBox(height: 12), // Botón Login ElevatedButton( onPressed: _cargando ? null : _iniciarSesion, style: ElevatedButton.styleFrom( backgroundColor: accentColor, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)), ), child: _cargando ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Text('INICIAR SESIÓN', style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.2)), ), const SizedBox(height: 16), // Registro Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('¿No tienes cuenta?', style: TextStyle(color: Colors.white60)), TextButton( onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const RegisterPage())), child: const Text('Regístrate', style: TextStyle(color: accentColor, fontWeight: FontWeight.bold)), ), ], ), ], ), ), ), ), ), ); } }
🧪 Prueba
  • Corre la app, ve a Login, intenta entrar con un correo que no existe — debe mostrar el error del backend en un SnackBar.
  • Inicia sesión con un usuario válido — debe navegar al Menu.
PASO 6
Pantalla Recuperar contraseña
🔒 Sin copiar
Flujo en 2 pasos dentro de la misma pantalla: primero pide el correo (_enviarCodigo), y cuando el backend confirma el envío, muestra los campos de código + nueva contraseña (_cambiarPassword).
DART · lib/pages/forgot_password.dart 🔒 import 'package:flutter/material.dart'; import 'package:ecommer_app/services/auth_service.dart'; class ForgotPasswordPage extends StatefulWidget { const ForgotPasswordPage({super.key}); @override State<ForgotPasswordPage> createState() => _ForgotPasswordPageState(); } class _ForgotPasswordPageState extends State<ForgotPasswordPage> { final _formKeyCorreo = GlobalKey<FormState>(); final _formKeyCodigo = GlobalKey<FormState>(); final _correoController = TextEditingController(); final _codigoController = TextEditingController(); final _passwordController = TextEditingController(); final _confirmarController = TextEditingController(); bool _cargando = false; bool _codigoEnviado = false; bool _verPassword = false; @override void dispose() { _correoController.dispose(); _codigoController.dispose(); _passwordController.dispose(); _confirmarController.dispose(); super.dispose(); } InputDecoration _inputStyle(String label, String hint, IconData icon, {Widget? suffix}) { return InputDecoration( labelText: label, labelStyle: const TextStyle(color: Colors.white70, fontSize: 14), hintText: hint, hintStyle: const TextStyle(color: Colors.white30), prefixIcon: Icon(icon, color: Colors.white38), suffixIcon: suffix, filled: true, fillColor: const Color(0xFF26221F), border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), contentPadding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), ); } Future<void> _enviarCodigo() async { if (!_formKeyCorreo.currentState!.validate()) return; setState(() => _cargando = true); try { await AuthService.solicitarCodigo(correo: _correoController.text.trim()); if (!mounted) return; setState(() => _codigoEnviado = true); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Revisa tu correo: te enviamos un código de 6 dígitos.')), ); } catch (error) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(error.toString().replaceFirst('Exception: ', '')), backgroundColor: Colors.redAccent, ), ); } finally { if (mounted) setState(() => _cargando = false); } } Future<void> _cambiarPassword() async { if (!_formKeyCodigo.currentState!.validate()) return; setState(() => _cargando = true); try { await AuthService.cambiarPassword( correo: _correoController.text.trim(), codigo: _codigoController.text.trim(), nuevaPassword: _passwordController.text, ); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Contraseña actualizada. Ya puedes iniciar sesión.')), ); Navigator.pop(context); } catch (error) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(error.toString().replaceFirst('Exception: ', '')), backgroundColor: Colors.redAccent, ), ); } finally { if (mounted) setState(() => _cargando = false); } } @override Widget build(BuildContext context) { const accentColor = Color(0xFFC88D51); return Scaffold( backgroundColor: const Color(0xFF181513), appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, iconTheme: const IconThemeData(color: Colors.white), ), body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 10), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Text( 'Recuperar contraseña', textAlign: TextAlign.center, style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold, color: Color(0xFFF5E6D3)), ), const SizedBox(height: 8), Text( _codigoEnviado ? 'Ingresa el código que te llegó al correo y tu nueva contraseña.' : 'Ingresa tu correo y te enviaremos un código de verificación.', textAlign: TextAlign.center, style: const TextStyle(color: Colors.white60, fontSize: 13), ), const SizedBox(height: 28), if (!_codigoEnviado) ...[ Form( key: _formKeyCorreo, child: TextFormField( controller: _correoController, keyboardType: TextInputType.emailAddress, style: const TextStyle(color: Colors.white), decoration: _inputStyle('Correo electrónico', 'correo@ejemplo.com', Icons.email_outlined), validator: (v) => (v == null || !v.contains('@')) ? 'Ingresa un correo válido' : null, ), ), const SizedBox(height: 24), ElevatedButton( onPressed: _cargando ? null : _enviarCodigo, style: ElevatedButton.styleFrom( backgroundColor: accentColor, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)), ), child: _cargando ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Text('ENVIAR CÓDIGO', style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.2)), ), ] else ...[ Form( key: _formKeyCodigo, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextFormField( controller: _codigoController, keyboardType: TextInputType.number, style: const TextStyle(color: Colors.white), decoration: _inputStyle('Código de verificación', '6 dígitos', Icons.pin_outlined), validator: (v) => (v == null || v.trim().length != 6) ? 'Ingresa el código de 6 dígitos' : null, ), const SizedBox(height: 14), TextFormField( controller: _passwordController, obscureText: !_verPassword, style: const TextStyle(color: Colors.white), decoration: _inputStyle( 'Nueva contraseña', 'Mínimo 6 caracteres', Icons.lock_outline_rounded, suffix: IconButton( icon: Icon(_verPassword ? Icons.visibility_off_rounded : Icons.visibility_rounded, color: Colors.white38), onPressed: () => setState(() => _verPassword = !_verPassword), ), ), validator: (v) => (v == null || v.length < 6) ? 'Mínimo 6 caracteres' : null, ), const SizedBox(height: 14), TextFormField( controller: _confirmarController, obscureText: !_verPassword, style: const TextStyle(color: Colors.white), decoration: _inputStyle('Confirmar contraseña', 'Repite tu contraseña', Icons.lock_outline_rounded), validator: (v) => (v != _passwordController.text) ? 'Las contraseñas no coinciden' : null, ), const SizedBox(height: 24), ElevatedButton( onPressed: _cargando ? null : _cambiarPassword, style: ElevatedButton.styleFrom( backgroundColor: accentColor, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)), ), child: _cargando ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Text('CAMBIAR CONTRASEÑA', style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.2)), ), const SizedBox(height: 8), TextButton( onPressed: _cargando ? null : () => setState(() { _codigoEnviado = false; _codigoController.clear(); }), child: const Text('¿No te llegó? Reenviar código', style: TextStyle(color: accentColor)), ), ], ), ), ], const SizedBox(height: 20), ], ), ), ), ); } }
🧪 Prueba
  • Desde Login, toca "¿Olvidaste tu contraseña?", ingresa tu correo y confirma que llegue el código de 6 dígitos.
  • Ingresa el código y una nueva contraseña — debe confirmar el cambio y volver a Login.
  • Inicia sesión con la nueva contraseña para confirmar que sí se actualizó.
PASO 7
Pantalla de Registro
🔒 Sin copiar
Formulario con 6 campos, usando FocusNode para saltar de campo en campo con el teclado. _registrar() llama a AuthService.registrar() y navega al Menu.
DART · lib/pages/register.dart 🔒 import 'package:flutter/material.dart'; import 'package:ecommer_app/services/auth_service.dart'; import 'package:ecommer_app/pages/menu.dart'; class RegisterPage extends StatefulWidget { const RegisterPage({super.key}); @override State<RegisterPage> createState() => _RegisterPageState(); } class _RegisterPageState extends State<RegisterPage> { final _formKey = GlobalKey<FormState>(); // Controladores de texto final _nombreController = TextEditingController(); final _apellidoController = TextEditingController(); final _telefonoController = TextEditingController(); final _correoController = TextEditingController(); final _passwordController = TextEditingController(); final _confirmarController = TextEditingController(); // Nodos de foco para saltar entre campos desde el teclado final _apellidoFocus = FocusNode(); final _telefonoFocus = FocusNode(); final _correoFocus = FocusNode(); final _passwordFocus = FocusNode(); final _confirmarFocus = FocusNode(); bool _cargando = false; bool _verPassword = false; @override void dispose() { // Liberación eficiente de memoria for (final c in [ _nombreController, _apellidoController, _telefonoController, _correoController, _passwordController, _confirmarController, ]) { c.dispose(); } for (final f in [ _apellidoFocus, _telefonoFocus, _correoFocus, _passwordFocus, _confirmarFocus, ]) { f.dispose(); } super.dispose(); } Future<void> _registrar() async { if (!_formKey.currentState!.validate()) return; setState(() => _cargando = true); try { await AuthService.registrar( nombre: _nombreController.text.trim(), apellido: _apellidoController.text.trim(), telefono: _telefonoController.text.trim(), correo: _correoController.text.trim(), password: _passwordController.text, ); if (!mounted) return; Navigator.pushAndRemoveUntil( context, MaterialPageRoute(builder: (_) => const Menu()), (route) => false, ); } catch (error) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(error.toString().replaceFirst('Exception: ', '')), backgroundColor: Colors.redAccent, ), ); } finally { if (mounted) setState(() => _cargando = false); } } InputDecoration _inputStyle(String label, String hint, IconData icon, {Widget? suffix}) { return InputDecoration( labelText: label, labelStyle: const TextStyle(color: Colors.white70, fontSize: 14), hintText: hint, hintStyle: const TextStyle(color: Colors.white30), prefixIcon: Icon(icon, color: Colors.white38), suffixIcon: suffix, filled: true, fillColor: const Color(0xFF26221F), border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), contentPadding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), ); } @override Widget build(BuildContext context) { const accentColor = Color(0xFFC88D51); return Scaffold( backgroundColor: const Color(0xFF181513), appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, iconTheme: const IconThemeData(color: Colors.white), ), body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 10), child: Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Text( 'Crea tu cuenta', textAlign: TextAlign.center, style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Color(0xFFF5E6D3)), ), const SizedBox(height: 6), const Text( 'Regístrate para pedir tu café favorito', textAlign: TextAlign.center, style: TextStyle(color: Colors.white60, fontSize: 13), ), const SizedBox(height: 24), // Nombre TextFormField( controller: _nombreController, textInputAction: TextInputAction.next, onFieldSubmitted: (_) => FocusScope.of(context).requestFocus(_apellidoFocus), style: const TextStyle(color: Colors.white), decoration: _inputStyle('Nombre', 'Tu nombre', Icons.person_outline_rounded), validator: (v) => (v == null || v.trim().length < 2) ? 'Ingresa tu nombre' : null, ), const SizedBox(height: 14), // Apellido TextFormField( controller: _apellidoController, focusNode: _apellidoFocus, textInputAction: TextInputAction.next, onFieldSubmitted: (_) => FocusScope.of(context).requestFocus(_telefonoFocus), style: const TextStyle(color: Colors.white), decoration: _inputStyle('Apellido', 'Tu apellido', Icons.person_outline_rounded), validator: (v) => (v == null || v.trim().length < 2) ? 'Ingresa tu apellido' : null, ), const SizedBox(height: 14), // Teléfono TextFormField( controller: _telefonoController, focusNode: _telefonoFocus, keyboardType: TextInputType.phone, textInputAction: TextInputAction.next, onFieldSubmitted: (_) => FocusScope.of(context).requestFocus(_correoFocus), style: const TextStyle(color: Colors.white), decoration: _inputStyle('Teléfono', '3001234567', Icons.phone_outlined), validator: (v) => (v == null || v.trim().length < 7) ? 'Ingresa un teléfono válido' : null, ), const SizedBox(height: 14), // Correo TextFormField( controller: _correoController, focusNode: _correoFocus, keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, onFieldSubmitted: (_) => FocusScope.of(context).requestFocus(_passwordFocus), style: const TextStyle(color: Colors.white), decoration: _inputStyle('Correo electrónico', 'correo@ejemplo.com', Icons.email_outlined), validator: (v) => (v == null || !v.contains('@')) ? 'Correo inválido' : null, ), const SizedBox(height: 14), // Contraseña TextFormField( controller: _passwordController, focusNode: _passwordFocus, obscureText: !_verPassword, textInputAction: TextInputAction.next, onFieldSubmitted: (_) => FocusScope.of(context).requestFocus(_confirmarFocus), style: const TextStyle(color: Colors.white), decoration: _inputStyle( 'Contraseña', 'Mínimo 6 caracteres', Icons.lock_outline_rounded, suffix: IconButton( icon: Icon(_verPassword ? Icons.visibility_off_rounded : Icons.visibility_rounded, color: Colors.white38), onPressed: () => setState(() => _verPassword = !_verPassword), ), ), validator: (v) => (v == null || v.length < 6) ? 'Mínimo 6 caracteres' : null, ), const SizedBox(height: 14), // Confirmar Contraseña TextFormField( controller: _confirmarController, focusNode: _confirmarFocus, obscureText: !_verPassword, textInputAction: TextInputAction.done, onFieldSubmitted: (_) => _registrar(), style: const TextStyle(color: Colors.white), decoration: _inputStyle('Confirmar contraseña', 'Repite tu contraseña', Icons.lock_outline_rounded), validator: (v) => (v != _passwordController.text) ? 'Las contraseñas no coinciden' : null, ), const SizedBox(height: 28), // Botón Registrarme ElevatedButton( onPressed: _cargando ? null : _registrar, style: ElevatedButton.styleFrom( backgroundColor: accentColor, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)), ), child: _cargando ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Text('REGISTRARME', style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.2)), ), const SizedBox(height: 20), ], ), ), ), ), ); } }
🧪 Prueba
  • Registra un usuario nuevo con un correo que no exista todavía — debe llevarte directo al Menu (queda logueado automáticamente).
  • Intenta registrar el mismo correo dos veces — el backend debe rechazar el segundo intento.
PASO 8
TopBar — ícono de persona navega a Login
🔒 Sin copiar
Se agrega un onProfileTap opcional al TopBar. El ícono de persona (arriba a la derecha, en Home/Menu) navega a LoginPage cuando se le pasa ese callback desde menu.dart (paso 9).
DART · lib/widgets/top_bar.dart 🔒 import 'package:flutter/material.dart'; class TopBar extends StatelessWidget { final VoidCallback? onProfileTap; const TopBar({super.key, this.onProfileTap}); @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // Saludo y Título 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, ), ), ], ), // Botones de acción Row( children: [ _buildIconButton( icon: Icons.person_outline_rounded, // Este es el ícono de "login": si no se pasa callback, no hace nada. onTap: onProfileTap ?? () {}, ), const SizedBox(width: 10), _buildIconButton( icon: Icons.notifications_none_rounded, onTap: () {}, ), ], ), ], ); } 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
  • Sin el paso 9 todavía no pasa nada al tocar el ícono — es normal, falta conectar el callback en el Menu.
PASO 9
Menu actualizado — TopBar conectado a Login
🔒 Sin copiar
Es tu mismo menu.dart: importa login.dart y le pasa onProfileTap al TopBar del paso 8, para que el ícono de persona navegue a Login. El tab "Perfil" de la barra inferior solo cambia de ícono seleccionado (no navega a ningún lado). También agrega el buscador funcional: _searchController + _busqueda guardan el texto, y _filtrarProductos() filtra la lista ya cargada por título (sin volver a pedir nada al backend).
DART · lib/pages/menu.dart 🔒 import 'package:flutter/material.dart'; import 'package:ecommer_app/widgets/top_bar.dart'; import 'package:ecommer_app/widgets/search_bar_with_filter.dart'; import 'package:ecommer_app/widgets/category_selector.dart'; import 'package:ecommer_app/widgets/product_card.dart'; import 'package:ecommer_app/models/product.dart'; import 'package:ecommer_app/services/product_service.dart'; import 'package:ecommer_app/pages/login.dart'; class Menu extends StatefulWidget { const Menu({super.key}); @override State<Menu> createState() => _MenuState(); } class _MenuState extends State<Menu> { int _selectedIndex = 0; late Future<List<Product>> _productsFuture; // Buscador: el texto se guarda aquí y se usa para filtrar la lista // que ya cargó _productsFuture (no se vuelve a pedir nada al backend). final TextEditingController _searchController = TextEditingController(); String _busqueda = ''; @override void initState() { super.initState(); _cargarProductos(); } @override void dispose() { _searchController.dispose(); super.dispose(); } // Método para volver a ejecutar la petición a MongoDB Future<void> _cargarProductos() async { setState(() { _productsFuture = ProductService.getProductos(); }); } // Filtra por título, ignorando mayúsculas/minúsculas List<Product> _filtrarProductos(List<Product> productos) { if (_busqueda.trim().isEmpty) return productos; final query = _busqueda.trim().toLowerCase(); return productos.where((p) => p.title.toLowerCase().contains(query)).toList(); } // Maneja el tap en la barra inferior. Favoritos, Carrito y Perfil // quedan pendientes de implementar. void _onNavTap(int index) { setState(() => _selectedIndex = index); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFF181513), body: SafeArea( // Permite deslizar hacia abajo para refrescar los datos de la BD child: RefreshIndicator( color: const Color(0xFFC88D51), backgroundColor: const Color(0xFF1F1B18), onRefresh: _cargarProductos, child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), slivers: [ // 1. ENCABEZADOS Y BÚSQUEDA SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(20.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TopBar( // Ícono de persona del TopBar -> navega a Login onProfileTap: () { Navigator.push( context, MaterialPageRoute(builder: (context) => const LoginPage()), ); }, ), const SizedBox(height: 24), SearchBarWithFilter( controller: _searchController, onChanged: (value) => setState(() => _busqueda = value), ), const SizedBox(height: 24), const CategorySelector(), const SizedBox(height: 12), ], ), ), ), // 2. GRILLA CONECTADA A LA BASE DE DATOS FutureBuilder<List<Product>>( future: _productsFuture, builder: (context, snapshot) { // Estado 1: Cargando datos desde la Base de Datos if (snapshot.connectionState == ConnectionState.waiting) { return const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(vertical: 40.0), child: Center( child: CircularProgressIndicator( color: Color(0xFFC88D51), ), ), ), ); } // Estado 2: Ocurrió un error al consultar la BD if (snapshot.hasError) { return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(vertical: 40.0), child: Center( child: Text( 'Error al obtener productos de la BD:\n${snapshot.error}', style: const TextStyle(color: Color(0xFFF5E6D3)), textAlign: TextAlign.center, ), ), ), ); } // Estado 3: La BD no devolvió productos if (!snapshot.hasData || snapshot.data!.isEmpty) { return const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(vertical: 40.0), child: Center( child: Text( 'No hay productos registrados en la base de datos', style: TextStyle(color: Color(0xFFF5E6D3)), ), ), ), ); } // Estado 4: Productos cargados con éxito de la BD, filtrados por la búsqueda final products = _filtrarProductos(snapshot.data!); // Estado 5: hay productos en la BD, pero ninguno coincide con la búsqueda if (products.isEmpty) { return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(vertical: 40.0), child: Center( child: Text( 'Sin resultados para "$_busqueda"', style: const TextStyle(color: Color(0xFFF5E6D3)), textAlign: TextAlign.center, ), ), ), ); } return SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 20.0), sliver: SliverGrid( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, mainAxisSpacing: 16.0, crossAxisSpacing: 16.0, childAspectRatio: 0.60, // Mantiene la relación corregida para evitar overflows ), delegate: SliverChildBuilderDelegate( (context, index) { final product = products[index]; return ProductCard( product: product, onTap: () { // Navegar al detalle del producto }, onAddTap: () { // Agregar al carrito }, ); }, childCount: products.length, ), ), ); }, ), const SliverToBoxAdapter( child: SizedBox(height: 24), ), ], ), ), ), bottomNavigationBar: BottomNavigationBar( currentIndex: _selectedIndex, onTap: _onNavTap, backgroundColor: const Color(0xFF1F1B18), type: BottomNavigationBarType.fixed, selectedItemColor: const Color(0xFFC88D51), unselectedItemColor: Colors.white.withValues(alpha: 0.35), showUnselectedLabels: false, items: const [ BottomNavigationBarItem(icon: Icon(Icons.home_filled), label: 'Inicio'), BottomNavigationBarItem(icon: Icon(Icons.favorite_rounded), label: 'Favoritos'), BottomNavigationBarItem(icon: Icon(Icons.shopping_bag_rounded), label: 'Carrito'), BottomNavigationBarItem(icon: Icon(Icons.person_rounded), label: 'Perfil'), ], ), ); } }
🧪 Prueba
  • Abre el Menu y toca el ícono de persona (arriba a la derecha) — debe llevarte a LoginPage.
  • Escribe en el buscador el nombre (o parte del nombre) de un producto existente — la grilla debe filtrarse en tiempo real, sin recargar ni mostrar el spinner.
  • Escribe algo que no exista — debe mostrar "Sin resultados para ...".
  • Borra el texto del buscador — deben volver a aparecer todos los productos.
PASO 10
main.dart — arranca siempre en Home
🔒 Sin copiar
MyApp define el tema oscuro de la app y siempre abre en HomePages. Desde ahí, el botón "Explorar menú" lleva al Menu, y dentro del Menu el ícono de persona lleva a Login (ver pasos 8 y 9).
DART · lib/main.dart 🔒 import 'package:flutter/material.dart'; import 'package:ecommer_app/pages/home.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Café Místico', debugShowCheckedModeBanner: false, theme: ThemeData( scaffoldBackgroundColor: const Color(0xFF181513), colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xFFC88D51), brightness: Brightness.dark, ), ), // Siempre arranca en la portada. Desde ahí, el botón "Explorar menú" // lleva al Menu, y dentro del Menu el ícono de persona del TopBar // lleva al Login (ver top_bar.dart y menu.dart). home: const HomePages(), ); } }
🧪 Prueba
  • Corre flutter run — la app debe abrir directo en la portada (Home, "Café Místico"), nunca en Login ni en Menu.
PASO 11
Prueba final: flujo completo
🧪 Prueba
Con el backend corriendo, prueba el flujo de punta a punta antes de dar la fase por terminada.
🧪 Checklist final
  • La app abre en Home → "Explorar menú" lleva al Menu con los productos.
  • El ícono de persona del Menu lleva a Login.
  • Registro crea un usuario nuevo y entra directo al Menu.
  • Login con ese usuario funciona, y con credenciales incorrectas muestra el error del backend.
  • "¿Olvidaste tu contraseña?" envía el código, y el cambio de contraseña funciona con el código recibido.
  • Cerrar la app y reabrirla mantiene la sesión (no vuelve a pedir login si ya iniciaste sesión).
← Anterior: Fase 4 · Pantallas Siguiente: Checklist →