Saltar al contenido principal

5 - Diseño UI Avanzado

Objetivo

Diseño UI Avanzado en Flutter

Objetivo

En este laboratorio aprenderemos sobre navegación entre pantallas, implementación del patrón Navigator 2.0, persistencia de datos local con SharedPreferences y algunos conceptos de Material Design.

Continuaremos desarrollando nuestra aplicación Pokedex implementada en los laboratorios anteriores.

Instrucciones

Sigue los pasos descritos en la siguiente práctica. Si tienes algún problema, recuerda que este tutorial está disponible para apoyarte.

API

Seguiremos utilizando la API de PokeAPI con los mismos endpoints:

GET https://pokeapi.co/api/v2/pokemon/?limit=1279
GET https://pokeapi.co/api/v2/pokemon/{number_pokemon}/

Laboratorio

Paso 1: Preparando los casos de uso

Hasta ahora hemos desarrollado la arquitectura básica de nuestro proyecto con MVVM y Clean Architecture. La aplicación muestra una lista de Pokémon, pero ahora es hora de mejorar la experiencia de usuario e interfaz.

Definiremos las siguientes historias de usuario para guiar nuestro desarrollo:

  • Como usuario, al entrar a la aplicación quiero iniciar sesión con mi correo para que mi sesión quede guardada.
  • Como usuario quiero poder elegir entre la lista del Pokedex o poder ver mi perfil.
  • Como usuario quiero ver el detalle de un Pokemon para ver más información del mismo.
  • Como usuario quiero cerrar sesión para borrar mis datos personales del dispositivo.

Este laboratorio nos enfocará en implementar:

  • Persistencia local
  • Navegación entre pantallas
  • Diálogos y alertas

Paso 2: Login e inicio de sesión con persistencia

Primero, vamos a crear una pantalla de login sencilla que permita al usuario ingresar su correo electrónico y guardar la sesión.

Creemos un nuevo archivo login_view.dart en la carpeta lib/framework/views:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../viewmodels/login_viewmodel.dart';

class LoginScreen extends StatelessWidget {
final VoidCallback onLogin;

const LoginScreen({Key? key, required this.onLogin}) : super(key: key);

@override
Widget build(BuildContext context) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 48),
const Text(
'Pokedex',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Consumer<LoginViewModel>(builder: (context, viewModel, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: viewModel.emailController,
decoration: const InputDecoration(
hintText: 'Correo Electrónico',
border: UnderlineInputBorder(),
),
textAlign: TextAlign.center,
keyboardType: TextInputType.emailAddress,
autocorrect: false,
textCapitalization: TextCapitalization.none,
),
],
);
}),
const Spacer(),
Consumer<LoginViewModel>(builder: (context, viewModel, child) {
return ElevatedButton(
onPressed: () {
viewModel.setCurrentUser();
if (!viewModel.hasError) {
onLogin();
}
},
child: const Text('Acceder'),
);
}),
const SizedBox(height: 24),
],
),
),
);
}
}

A continuación, necesitamos crear el ViewModel para manejar la lógica de nuestro login:

// lib/framework/viewmodels/login_viewmodel.dart

import 'package:flutter/material.dart';
import 'package:flutter_application_1/domain/user_usecases.dart';

class LoginViewModel extends ChangeNotifier {
final emailController = TextEditingController();
final UserUseCases _userUseCases = UserUseCases();

String _errorMessage = '';
bool _hasError = false;

String get errorMessage => _errorMessage;
bool get hasError => _hasError;

LoginViewModel() {
_checkCurrentUser();
}

void _checkCurrentUser() async {
final email = await _userUseCases.getCurrentUser();
if (email != null && email.isNotEmpty) {
emailController.text = email;
}
}

void setCurrentUser() {
final email = emailController.text.trim();

if (email.isEmpty) {
_errorMessage = 'Correo inválido';
_hasError = true;
notifyListeners();

// Mostrar SnackBar o Dialog aquí
return;
}

_userUseCases.setCurrentUser(email);
_hasError = false;
notifyListeners();
}

@override
void dispose() {
emailController.dispose();
super.dispose();
}
}

Paso 3: Implementando persistencia local con SharedPreferences

Ahora, vamos a implementar la persistencia local utilizando SharedPreferences.

Primero, agregamos la dependencia en nuestro pubspec.yaml:

dependencies:
shared_preferences: ^2.1.1

Luego, creamos nuestro servicio local:

// lib/data/services/local_storage_service.dart

abstract class LocalStorageService {
Future<String?> getCurrentUser();
Future<void> setCurrentUser(String email);
Future<void> removeCurrentUser();
}

Creamos el repositorio para acceder a este servicio:

// lib/data/repositories/user_repository_impl.dart

import 'package:flutter_application_1/data/services/local_storage_service.dart';
import 'package:shared_preferences/shared_preferences.dart';

class UserRepository implements LocalStorageService {
@override
Future<String?> getCurrentUser() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString('currentUser');
}

@override
Future<void> setCurrentUser(String email) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('currentUser', email);
}

@override
Future<void> removeCurrentUser() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('currentUser');
}
}

Y finalmente, creamos nuestros casos de uso:

// lib/domain/user_usecases.dart

import 'package:flutter_application_1/data/repositories/user_repository.dart';

class UserUseCases {
final UserRepository _repository;

UserUseCases({UserRepository? repository})
: _repository = repository ?? UserRepositoryImpl();

Future<String?> getCurrentUser() async {
return await _repository.getCurrentUser();
}

Future<void> setCurrentUser(String email) async {
await _repository.setCurrentUser(email);
}

Future<void> removeCurrentUser() async {
await _repository.removeCurrentUser();
}
}

Paso 4: Implementando el TabBar para navegación entre secciones

Ahora crearemos un menú con pestañas para navegar entre la lista de Pokémon y el perfil del usuario:

// lib/framework/views/main_tab_screen.dart

import 'package:flutter/material.dart';
import 'package:flutter_application_1/framework/views/content_view.dart';
import 'package:flutter_application_1/framework/views/profile_view.dart';

class MainTabScreen extends StatefulWidget {
final VoidCallback onLogout;

const MainTabScreen({
Key? key,
required this.onLogout,
}) : super(key: key);

@override
_MainTabScreenState createState() => _MainTabScreenState();
}

class _MainTabScreenState extends State<MainTabScreen> {
int _currentIndex = 0;

@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _currentIndex,
children: [
const ContentView(),
ProfileScreen(onLogout: widget.onLogout),
],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.catching_pokemon),
label: 'Pokedex',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: 'Perfil',
),
],
),
);
}
}

Paso 5: Implementando el detalle de Pokémon

Vamos a crear la pantalla de detalle de Pokémon:

// lib/framework/views/pokemon_detail_screen.dart

import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_application_1/data/modelos/pokedex.dart';

class PokemonDetailScreen extends StatelessWidget {
final PokemonBase pokemon;

const PokemonDetailScreen({
Key? key,
required this.pokemon,
}) : super(key: key);

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(pokemon.pokemon.name),
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (pokemon.profile?.sprites.frontDefault != null)
Hero(
tag: 'pokemon-${pokemon.id}',
child: CachedNetworkImage(
imageUrl: pokemon.profile!.sprites.frontDefault,
height: 200,
width: 200,
placeholder: (context, url) => const CircularProgressIndicator(),
errorWidget: (context, url, error) => const Icon(Icons.error),
),
),

const SizedBox(height: 16),

Text(pokemon.pokemon.name.toUpperCase(),
style: Theme.of(context).textTheme.headlineMedium),

const SizedBox(height: 8),

Text('ID: #${pokemon.id}'),

// Aquí puedes agregar más información como tipos,
// estadísticas, movimientos, etc.
],
),
),
),
);
}
}

Y modificamos nuestra pantalla de lista para navegar al detalle:

// lib/framework/views/content_view.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../viewmodels/pokemon_list_viewmodel.dart';
import 'package:flutter_application_1/framework/views/pokemon_detail_screen.dart';

onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
PokemonDetailScreen(pokemon: pokemonBase),
),
);
},

Paso 6: Implementando la pantalla de perfil y cierre de sesión

Vamos a crear la pantalla de perfil que muestra el correo del usuario y permite cerrar sesión:

// lib/framework/views/profile_view.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../viewmodels/profile_viewmodel.dart';

class ProfileScreen extends StatelessWidget {
final VoidCallback onLogout;

const ProfileScreen({
Key? key,
required this.onLogout,
}) : super(key: key);

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Perfil'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircleAvatar(
radius: 50,
child: Icon(Icons.person, size: 50),
),

const SizedBox(height: 24),

Consumer<ProfileViewModel>(builder: (context, viewModel, child) {
return Text(
viewModel.email,
style: Theme.of(context).textTheme.headlineSmall,
);
}),

const SizedBox(height: 48),

Consumer<ProfileViewModel>(builder: (context, viewModel, child) {
return ElevatedButton.icon(
onPressed: () {
viewModel.logout();
onLogout();
},
icon: const Icon(Icons.logout, color: Colors.red),
label: const Text('Cerrar Sesión',
style: TextStyle(color: Colors.red)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
side: const BorderSide(color: Colors.red),
),
);
}),
],
),
),
);
}
}

Y su respectivo ViewModel:

// lib/framework/viewmodels/profile_viewmodel.dart

import 'package:flutter/material.dart';
import 'package:flutter_application_1/domain/user_usecases.dart';

class ProfileViewModel extends ChangeNotifier {
final UserUseCases _userUseCases = UserUseCases();
String _email = '';

String get email => _email;

ProfileViewModel() {
_loadUserEmail();
}

Future<void> _loadUserEmail() async {
final email = await _userUseCases.getCurrentUser();
if (email != null) {
_email = email;
notifyListeners();
}
}

Future<void> logout() async {
await _userUseCases.removeCurrentUser();
_email = '';
notifyListeners();
}
}

Paso 7: Implementando la navegación con Navigator 2.0

Finalmente, implementaremos el patrón Navigator 2.0 para manejar la navegación de forma declarativa

// lib/framework/navigation/app_router.dart

import 'package:flutter/material.dart';
import '../login_view.dart';
import '../main_tab_screen.dart';

enum AppRoute {
login,
main,
}

class AppRouter extends RouterDelegate<AppRoute>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<AppRoute> {

@override
final GlobalKey<NavigatorState> navigatorKey;

AppRoute _currentRoute = AppRoute.login;

AppRouter() : navigatorKey = GlobalKey<NavigatorState>() {
_checkInitialRoute();
}

AppRoute get currentRoute => _currentRoute;

Future<void> _checkInitialRoute() async {
// Aquí podríamos verificar si el usuario ya tiene sesión
// y establecer la ruta inicial adecuada
}

void navigateToMain() {
_currentRoute = AppRoute.main;
notifyListeners();
}

void navigateToLogin() {
_currentRoute = AppRoute.login;
notifyListeners();
}

@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [
if (_currentRoute == AppRoute.login)
MaterialPage(
key: const ValueKey('LoginPage'),
child: LoginScreen(
onLogin: navigateToMain,
),
),

if (_currentRoute == AppRoute.main)
MaterialPage(
key: const ValueKey('MainPage'),
child: MainTabScreen(
onLogout: navigateToLogin,
),
),
],
onPopPage: (route, result) {
if (!route.didPop(result)) {
return false;
}
return true;
},
);
}

@override
Future<void> setNewRoutePath(AppRoute configuration) async {
_currentRoute = configuration;
return;
}
}

Finalmente, actualizamos nuestro main.dart para usar nuestro router:

// lib/main.dart

import 'package:flutter/material.dart';
import 'framework/navigation/app_router.dart';

void main() {
runApp(
const PokedexApp()); // runApp corre la instancia de nuestra aplicación principal
}

class PokedexApp extends StatelessWidget {
const PokedexApp({Key? key})
: super(
key:
key); // super(key: key) → Pasa la clave (key) al constructor de StatelessWidget (opcional, útil en listas para identificar widgets únicos).

@override
Widget build(BuildContext context) {
// Recibe un BuildContext, que contiene información sobre el árbol de widgets.
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => PokemonListViewModel()),
ChangeNotifierProvider(create: (_) => LoginViewModel()),
ChangeNotifierProvider(create: (_) => ProfileViewModel()),
],
child: MaterialApp(
// Define nuestro entorno visual
title:
'Pokedex', // Define el título de la aplicación, que aparece en la barra de tareas en algunas plataformas.
theme: ThemeData(
// Define el tema de la aplicación con la clase ThemeData.
colorScheme: ColorScheme.fromSeed(
seedColor: Colors
.red), // Genera una paleta de colores a partir de un color base (Colors.red).
useMaterial3: true,
),
home: Router(
routerDelegate: AppRouter(),
backButtonDispatcher: RootBackButtonDispatcher(),
),
));
}
}

¡Con esto hemos completado todas nuestras historias de usuario!

  • ✓ Como usuario, al entrar a la aplicación quiero iniciar sesión con mi correo para que mi sesión quede guardada.
  • ✓ Como usuario quiero poder elegir entre la lista del Pokedex o poder ver mi perfil.
  • ✓ Como usuario quiero ver el detalle de un Pokemon para ver más información del mismo.
  • ✓ Como usuario quiero cerrar sesión para borrar mis datos personales del dispositivo.

Retos adicionales

  1. Mejora la pantalla de detalle del Pokémon para mostrar más información como tipos, habilidades, estadísticas, etc.
  2. Implementa una función de búsqueda en la lista de Pokémon.
  3. Añade la funcionalidad para que el usuario pueda subir una foto de perfil utilizando la cámara del dispositivo.
  4. Añade animaciones y transiciones personalizadas entre pantallas para mejorar la experiencia de usuario.
  5. Implementa un modo oscuro y claro para la aplicación.