Cubit en Práctica: Creando una Aplicación de Noticias Responsiva y con Soporte de Múltiples Idiomas. Parte 3

Esta es la tercera parte de esta serie de artículos, donde creamos una aplicación de noticias responsiva con soporte para tres idiomas: español, inglés y chino. Además, implementaremos una arquitectura escalable y añadiremos pruebas unitarias y de widgets.

Recuerda que puedes encontrar el código fuente en GitHub. Y no olvides visitar los otros artículos de esta serie de tutoriales:

Capa de presentación: La interfaz de usuario

Como mencionamos antes, la capa de presentación se encarga de las interacciones y de lo que el usuario puede ver: botones, imágenes, texto, etc.

Tenemos que crear una aplicación con soporte para tres tamaños de pantalla diferentes:

App responsive con soporte para tres tamaños

App responsive con soporte para tres tamaños

Interfaz de usuario: Computadora

Comenzaremos con el tamaño de pantalla más grande que sería para una computadora de escritorio. Vamos a descomponer la interfaz de usuario en diferentes partes:

Podemos identificar tres partes principales:

  1. Barra de búsqueda: El usuario puede introducir texto para buscar noticias de su interés.

  2. Lista de noticias: Se compone de varias noticias; al dar clic a una de ellas, veremos los detalles de la noticia.

  3. Detalles de la noticia: El usuario puede ver los detalles de la noticia, y al dar clic en "ver más", se abre el navegador con la página de la noticia.

Cada una de estas partes se puede descomponer en partes aún más pequeñas, pero por el momento vamos a trabajar con estas tres. Para la barra de búsqueda, vamos a crear un widget llamado SearchWidget. Para los detalles de las noticias, creamos un widget llamado DetailsWidget, y para cada ítem del listado de noticias, el widget se llamará ListItem.

Primero creamos el SearchWidget:

// En lugar de usar un StatefulWidget, usamos Flutter Hooks.
class SearchWidget extends HookWidget {
  const SearchWidget({
    super.key,
    required this.onChanged,
    required this.onClearPress,
  });

  // "Callback" que se ejecuta cuando presionamos el botón de borrar en la barra de búsqueda.
  final VoidCallback onClearPress;

  // "Callback" que se ejecuta cada vez que el término de búsqueda cambia.
  final ValueChanged<String> onChanged;

  @override
  Widget build(BuildContext context) {
    // Creamos un TextEditingController con Flutter Hooks.
    final controller = useTextEditingController();

    // Estado que nos ayuda a mostrar u ocultar el botón de borrar
    // si en la barra de búsqueda hay texto o no.
    final searchTermEmpty = useState(controller.text.isEmpty);

    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: TextFormField(
        controller: controller,
        onChanged: (text) {

          // Retrasamos la llamada a "onChanged" para evitar múltiples llamadas a la API.
          EasyDebounce.debounce(
            'search-news',
            const Duration(milliseconds: 300),
            () => onChanged.call(text),
          );
          searchTermEmpty.value = controller.text.isEmpty;
        },
        decoration: InputDecoration(
          prefixIcon: const Icon(Icons.search),
          labelText: LocaleKeys.search_news.tr(),
          
          // Ocultamos o mostramos el botón de borrar si hay texto o no.
          suffixIcon: searchTermEmpty.value
              ? null
              : IconButton(
                  onPressed: () {
                    controller.clear();
                    onClearPress.call();
                  },
                  icon: const Icon(Icons.clear),
                ),
          border: const OutlineInputBorder(),
        ),
      ),
    );
  }
}

Aprende más

En el widget SearchWidget hemos utilizado los siguientes paquetes:

  • Flutter Hooks: Nos ayuda a eliminar código repetitivo y lo hace más fácil de leer. Aprende más sobre él en este videotutorial.

  • EasyDebounce: Este paquete evita hacer llamadas excesivas a la API cuando el usuario escribe en la barra de búsqueda.

Ahora procederemos a crear el DetailsWidget:

class DetailsWidget extends StatelessWidget {
  
  // En el constructor, inicializamos la noticia de la cual mostraremos los detalles.
  const DetailsWidget({
    Key? key,
    required this.article,
  });

  // La noticia de la que mostraremos los detalles.
  final Article article;

  @override
  Widget build(BuildContext context) {

    // Usamos `double.infinity` en la altura del `SizedBox` para ocupar toda la altura disponible.
    return SizedBox(
      height: double.infinity,
      child: SingleChildScrollView(
        child: Column(
          children: [
            // Si la noticia no tiene imagen, mostramos un contenedor de color rojo;
            // de lo contrario, utilizamos `CachedNetworkImage` para mostrarla.
            article.urlToImage == null
                ? Container(color: Colors.red, height: 250)
                : CachedNetworkImage(
                    width: 450,
                    imageUrl: article.urlToImage!,
                    placeholder: (context, url) => const Center(
                      child: CircularProgressIndicator(),
                    ),
                    errorWidget: (context, url, error) =>
                        const Icon(Icons.error),
                  ),
            Text(
              article.title,
              style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
            ),
            const SizedBox(height: 8),
            Text('${article.content}'),
            const SizedBox(height: 8),

            // Al presionar el botón, abriremos la noticia en el navegador.
            ElevatedButton(
              onPressed: () => launchUrlString(article.url),
              child: const Text('Ver más'),
            ),
            const SizedBox(height: 16),
          ],
        ),
      ),
    );
  }
}

Ahora vamos a crear el widget ListItem:

// Constante de ayuda que define el ancho y la altura de la imagen 
const _imageSize = 120.0;

class ListItem extends StatelessWidget {
  
  // En el constructor, inicializamos la noticia de la que mostraremos los detalles.
  const ListItem({
    Key? key,
    required this.article,
    required this.onTap,
  });

  // La noticia de la que mostraremos los detalles.
  final Article article;

  // Función de devolución de llamada que se ejecuta cuando se toca este widget.
  final GestureTapCallback onTap;

  @override
  Widget build(BuildContext context) {
    // GestureDetector nos ayuda a detectar si este widget ha sido presionado.
    return GestureDetector(
      onTap: onTap,
      child: Card(
        margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // `_Image` es un widget privado que nos ayuda a mostrar la imagen o
            // mostrar un contenedor vacío si la noticia no tiene imagen.
            _Image(url: article.urlToImage),
            Expanded(
              child: Padding(
                padding: const EdgeInsets.all(8.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    // Mostramos el título de la noticia.
                    Text(
                      article.title,
                      maxLines: 2,
                      overflow: TextOverflow.ellipsis,
                      style: const TextStyle(
                        fontWeight: FontWeight.bold,
                        fontSize: 16,
                      ),
                    ),
                    // Si la noticia tiene descripción, la mostramos.
                    if (article.description != null) ...[
                      const SizedBox(height: 16),
                      Text(
                        article.description!,
                        maxLines: 3,
                        overflow: TextOverflow.ellipsis,
                      )
                    ]
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// `_Image` es un widget privado que nos ayuda a mostrar la imagen de la noticia.
class _Image extends StatelessWidget {

  const _Image({
    required this.url,
  });

  final String? url;

  @override
  Widget build(BuildContext context) {
    
    // Si la URL es nula, mostramos un contenedor de color rojo.
    return url == null
        ? Container(
            width: _imageSize,
            height: _imageSize,
            color: Colors.red,
          )
         
        // Si la URL no es nula, utilizamos `CachedNetworkImage` para mostrar la imagen.
        : CachedNetworkImage(
            width: _imageSize,
            height: _imageSize,
            imageUrl: url!,
            fit: BoxFit.cover,
            
            // Mientras la imagen se carga, mostramos un CircularProgressIndicator.
            placeholder: (context, url) => const SizedBox(
              width: _imageSize,
              height: _imageSize,
              child: Center(
                child: CircularProgressIndicator(),
              ),
            ),
            
            // En caso de algún error, mostramos el icono de error.
            errorWidget: (context, url, error) => const Icon(Icons.error),
          );
  }
}

Ya hemos construido las tres partes principales: la barra de búsqueda SearchWidget, los detalles de la noticia DetailsWidget, y el widget que representa cada una de las noticias en la lista de noticias ListItem. El siguiente paso es integrar NewsCubit con la interfaz de usuario.

Integrar NewsCubit con la interfaz de usuario

Vamos a crear la pantalla de inicio, que se mostrará cuando los usuarios abran la aplicación. La llamaremos NewsScreen.

class NewsScreen extends StatelessWidget {
  const NewsScreen({Key? key});

  @override
  Widget build(BuildContext context) {
    
    // Obtenemos el "locale" actual de la aplicación.
    final locale = Localizations.localeOf(context);

    // Usamos BlocProvider para crear un NewsCubit y agregarlo al árbol de widgets.
    return BlocProvider<NewsCubit>(

      // Creamos un NewsCubit con el "locale" actual y llamamos la función searchNews
      // para iniciar la búsqueda en cuanto la pantalla NewsScreen sea visible.
      create: (context) => NewsCubit(locale)..searchNews(),
      child: Scaffold(
        appBar: AppBar(
          title: Text('Aplicación de noticias'),
        ),

        // Usamos BlocBuilder para actuar de acuerdo con los estados de NewsCubit.
        body: BlocBuilder<NewsCubit, NewsState>(
          builder: (context, state) {
            
           // Dependiendo del estado, vamos a devolver diferentes widgets.
           return ?????;

          },
        ),
      ),
    );
  }
}

Ahora podemos mostrar la pantalla NewsScreen en cuanto la aplicación sea lanzada de la siguiente forma:

void main() async {

  // Inyectamos las dependencias al iniciar la aplicación.
  injectDependencies();

  runApp(

    // Creamos un MaterialApp que muestra la pantalla NewsScreen.
    const MaterialApp(
      title: 'Flutter Demo',
      home: NewsScreen(),
    ),

  );
}

Dependiendo del estado actual del cubit, debemos devolver un widget diferente en la función builder del BlocBuilder:

    BlocBuilder<NewsCubit, NewsState>(
      builder: (context, state) {

        // Usamos una columna para alinear los widgets.
        return Column(
          children: [

            // El primer widget es la barra de búsqueda.
            SearchWidget(
              // Cuando el texto cambia, si es mayor de tres caracteres
              // llamamos searchNews para realizar una nueva búsqueda.
              onChanged: (text) {
                context
                    .read<NewsCubit>()
                    .searchNews(search: text.length > 3 ? text : null);
              },

              // Cuando presionamos el botón de borrar, llamamos clearSearch para hacer una nueva búsqueda sin término de búsqueda.                 
              onClearPress: context
                  .read<NewsCubit>()
                  .clearSearch,
            ),

            // Luego tenemos un "Expanded" porque el contenido después de la 
            // barra de búsqueda ocupará el espacio restante.
            Expanded(
              // El "Builder" no es necesario, pero ayuda a usar "if else" de forma más organizada.
              child: Builder(
                builder: (context) {
                  if (state is NewsSuccessState) {
                    // Si el estado es exitoso, mostramos el widget _BigSize.
                    return _BigSize(news: state.news);

                  } else if (state is NewsErrorState) {

                    // Si el estado es de error, mostramos un texto con el error.
                    return Text(state.message);

                  } else {

                    // Si el estado es de carga, mostramos un indicador de carga.
                    return const Center(
                      child: CircularProgressIndicator(),
                    );
                  }
                },
              ),
            ),
          ],
        );
      },
    )

Muy bien, hemos implementado el contenido del BlocBuilder pero hay un widget que no hemos creado aun. _BigSize es el widget que contiene la estructura de la interfaz de usuario para pantallas grandes. Veamos como implementarlo:

class _BigSize extends HookWidget {

  // En el constructor inicializamos la lista de noticias
  const _BigSize({required this.news});

  final List<Article> news;

  @override
  Widget build(BuildContext context) {

    // Usamos hooks para mantener el estado de la noticia seleccionada
    final currentNew = useState(news.firstOrNull);


    // Usamos Row para separar la pantalla en dos. El listado de noticias 
    // del lado izquierdo y los detalles de la noticia del derecho
    return Row(
      children: [
        // El listado de noticias tiene un ancho fijo de 450
        SizedBox(
          width: 450,
          child: ListView.builder(
            itemCount: news.length,

            // Al presionar un item de la lista actualizamos el estado local con la noticia seleccionada
            itemBuilder: (_, int index) => ListItem(
              article: news[index],
              onTap: () => currentNew.value = news[index],
            ),
          ),
        ),
        // Mostramos los detalles de la noticia seleccionada
        if (currentNew.value != null)
          Expanded(
            child: DetailsWidget(
              article: currentNew.value!,
            ),
          ),
      ],
    );
  }
}

Ahora podemos correr la aplicacion y ver el resultado.

Aplicación sin soporte para Tabletas o iPads ni teléfonos celulares

Aplicación sin soporte para Tabletas o iPads ni teléfonos celulares

La aplicación se ve bien, podemos seleccionar diferentes noticias, pero el problema es que la aplicación no se adapta a los cambios de pantalla y muestra errores de desbordamiento. Continuemos agregando soporte para otros tamaños de pantalla.

Interfaz de usuario: Tablet o iPad

Es hora de agregar soporte para tabletas y iPad, en este caso el listado de noticias y los detalles van a estar en pantallas diferentes como podemos ver en la siguiente imagen

1. Al presionar una noticia del listado navegamos a los detalles de la noticia

1. Al presionar una noticia del listado navegamos a los detalles de la noticia

Vamos a crear una pantalla nueva que llamare NewsDetailScreen y nos servirá para ver los detalles de la noticia:


// Definimos un tipo que usaremos para pasar la noticia
// como argumento durante la navegación
typedef NewsDetailScreenArgs = ({Article article});

class NewsDetailScreen extends StatelessWidget {
  const NewsDetailScreen({super.key});

  @override
  Widget build(BuildContext context) {
    // Obtenemos la noticia
    final article = context.args<NewsDetailScreenArgs>().article;

    // Creamos un Scaffold con un AppBar y usamos el widget DetailsWidget
    // para mostrar los detalles de la noticia
    return Scaffold(
      appBar: AppBar(
        title: const Text('News Details'),
      ),
      body: DetailsWidget(article: article),
    );
  }
}

En el código anterior podemos ver que la clase NewsDetailScreen es muy sencilla ya que estamos reutilizando el widget DetailsWidget.

Para la navegación vamos a usar rutas por lo que vamos a crear una nueva clase llamada Routes que se encargara de administrar las rutas.

class Routes {
  // Ruta raiz
  static const newsScreen = '/';

  // Ruta de detalles de la noticia
  static const detailsScreen = '/details';

  static Route routes(RouteSettings settings) {

    // Función de ayuda para crear MaterialPageRoute y no
    // tener mucho código repetitivo
    MaterialPageRoute buildRoute(Widget widget) {
      return MaterialPageRoute(builder: (_) => widget, settings: settings);
    }

    // Usamos un switch para navegar a la ruta deseada, si la ruta 
    // no existe lanzamos una excepción
    return switch (settings.name) {
      newsScreen => buildRoute(const NewsScreen()),
      detailsScreen => buildRoute(const NewsDetailScreen()),
      _ => throw Exception('Route does not exists'),
    };
  }
}

También tenemos que modificar MaterialApp para usar onGenerateRoute en lugar de home.

    return MaterialApp(
      title: 'Flutter Demo',
      onGenerateRoute: Routes.routes,
    );

Muy bien, ya tenemos lista la navegación, pero también tenemos que agregar el soporte para tabletas y iPads, vamos a crear un nuevo widget privado que se encargara de mostrar el listado en pantallas medianas:

class _MediumSize extends StatelessWidget {

  // En el constructor inicializamos la lista de noticias
  const _MediumSize({required this.news});

  final List<Article> news;

  @override
  Widget build(BuildContext context) {

    // Creamos el listado de noticias
    return ListView.builder(
      itemCount: news.length,
      itemBuilder: (_, int index) => ListItem(
        article: news[index],
        onTap: () {
          // Al presionar un ítem de la lista vamos a 
          // navegar a la pantalla de detalles
          final args = (article: news[index]);
          Navigator.pushNamed(context, Routes.detailsScreen, arguments: args);
        },
      ),
    );
  }
}

Y por último para decidir si mostrar el widget _BigSize o el widget _MediumSize vamos a utilizar un LayoutBuilder de la siguiente forma:

    return LayoutBuilder(
      builder: (context, constraints) {
        // Si el ancho máximo disponible es menor de 900 unidades entonces
        // mostraremos _MediumSize de lo contrario mostramos _BigSize
        return switch (constraints.maxWidth) {
          < 900 => _MediumSize(news: state.news),
          _ => _BigSize(news: state.news),
        };
      },
    );

Si corremos la aplicación y cambiamos el tamaño de la pantalla podemos ver que la interfaz de usuario se adapta correctamente.

Interfaz de usuario: Celular/Móvil

Es hora de agregar soporte para teléfonos celulares. Pero esta parte es una tarea para el lector así que vamos como es la interfaz de usuario:

1. Al presionar una noticia del listado navegamos a los detalles de la noticia

1. Al presionar una noticia del listado navegamos a los detalles de la noticia

La interfaz de usuario es muy parecida a la interfaz de usuario para tabletas así que no será muy difícil de crear, pero aquí te dejo unos consejos:

  • Hay que crear un nuevo widget para representar cada noticia del listado de noticias

  • Hay que crear un nuevo widget para mostrar el listado de noticias. Para tener consistencia en el código yo lo llamaría _SmallSize

  • Hay que modificar la lógica del LayoutBuilder para mostrar el widget _SmallSize

  • NO no hay que crear ninguna pantalla ni rutas nuevas.

Si tienes algún problema recuerda que el código fuente lo puedes descargar de Github.

Soporte de múltiples idiomas

Ahora vamos a agregar soporte para múltiples idiomas a la aplicación. Utilizaremos el paquete de EasyLocalization que simplifica el proceso de agregar múltiples idiomas. Aprende más de este paquete en este videotutorial.

Primero tenemos que decidir que idiomas queremos soportar, podemos ver la documentación de las dos APIS que vamos a soportar:

  1. /v2/everything: Soporta el parámetro language con valores como es, en, zh y muchos más.

  2. /v2/top-headlines: Soporta el parámetro country con valores como mx, ve, us, tw y muchos más.

En esta aplicación solo vamos a agregar algunos idiomas y países. Comenzamos creando una lista de "Locales" suportados:

const spanishMx = Locale('es', 'MX'); // Español de México
const spanishVe = Locale('es', 'VE'); // Español de Venezuela
const englishUs = Locale('en', 'US'); // Ingles de EUA
const chineseTw = Locale('zh', 'TW'); // Chino de Taiwán

// Mapa con el nombre de los idiomas soportados
final supportedLocales = <Locale, String>{
  englishUs: 'English - US',
  spanishMx: 'Español - MX',
  spanishVe: 'Español - VE',
  chineseTw: '中文 - TW',
};

También necesitamos tres archivos JSON con las traducciones de los idiomas soportados.

Para ingles tenemos en.json

{
  "top_headlines": "Top Headlines",
  "search_news": "Search News",
  "view_more": "View more"
}

Para español tenemos es.json

{
  "top_headlines": "Titulares Principales",
  "search_news": "Buscar Noticias",
  "view_more": "Ver más"
}

Y para el chino tenemos zh.json

{
  "top_headlines": "最新的新聞",
  "search_news": "找新聞",
  "view_more": "看更多"
}

EasyLocalization soporta generación de código que crea cada una de las llaves en un mapa y es más fácil acceder a ellas desde el código. Por ejemplo:

// Sin generación de código podemos comente errores como el siguiente:
Text('top_headLines'.tr());

// ¿Encontraste el error? En vez de escribir 'top_headlines' escribimos 'top_headLines'.

// Pero con generación de código no podemos cometer ningún error:
Text(LocaleKeys.top_headlines.tr())

Vamos a correr el siguiente comando para generar todo el código necesario:

flutter pub run easy_localization:generate -f keys -S resources/translations -O lib/src/localization -o locale_keys.g.dart
flutter pub run easy_localization:generate -S resources/translations -O lib/src/localization

Nota

No te preocupes si no entiendes el comando anterior. En él código fuente puedes encontrar el archivo generate_code.sh y ejecutarlo desde la terminal o consola y generara todo el código necesario.

Cuando ejecutemos el comando anterior, se van a generar los archivos codegen_loader.g.dart y locale_keys.g.dart dentro de la carpeta lib/src/localization.

Después de generar el código necesario vamos a inicializar el paquete EasyLocalization para esto vamos a modificar la función main() de la siguiente manera:

void main() async {

  // Nos aseguramos de inicializar WidgetsFlutterBinding y EasyLocalization
  WidgetsFlutterBinding.ensureInitialized();
  await EasyLocalization.ensureInitialized();

  injectDependencies();
  runApp(const MyApp());
}

También tenemos que configurar EasyLocalization y encerrar nuestro MaterialApp con el widget EasyLocalization:

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return EasyLocalization(
      // El idioma de respaldo es el inglés de EUA
      fallbackLocale: englishUs,
      useFallbackTranslations: true,

      // Pasamos la lista de los idiomas que soportamos
      supportedLocales: supportedLocales.keys.toList(),

      // path no es necesario porque estamos usando código generado
      path: 'xxx',

      // Pasamos la clase generada
      assetLoader: const CodegenLoader(),

      // Encerramos MaterialApp con el widget EasyLocalization
      child: const _MaterialApp(),
    );
  }
}

class _MaterialApp extends StatelessWidget {
  const _MaterialApp();

  @override
  Widget build(BuildContext context) {
    // Configuramos MaterialApp para soportar diferentes idiomas
    return MaterialApp(
      title: 'Flutter Demo',
      locale: context.locale,
      onGenerateRoute: Routes.routes,
      debugShowCheckedModeBanner: false,
      supportedLocales: context.supportedLocales,
      localizationsDelegates: context.localizationDelegates,
    );
  }
}

Ahora vamos a regresar a la clase NewsScreen y hacer dos modificaciones, la primera modificación es usar la extensión de EasyLocalization para obtener el locale actual. Así que vamos a reemplazar el siguiente código:

// Reemplazamos esta línea de código 
final locale = Localizations.localeOf(context);

// por
final locale = context.locale;

La diferencia en el código anterior es que usar la extensión de EasyLocalization es mucho más corto.

También tenemos que agregar un PopupMenuButton que nos permita seleccionar entre los idiomas que soporta nuestra aplicación:

PopupMenuButton para seleccionar el idioma

PopupMenuButton para seleccionar el idioma

Vamos a crean un widget llamado _LanguagePopUpMenu que nos permitirá mostrar un menú para poder seleccionar entre los idiomas soportados:

class _LanguagePopUpMenu extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Creamos un PopupMenuButton con el icono de traducir
    return PopupMenuButton<Locale>(
      icon: const Icon(Icons.translate),
      itemBuilder: (context) {
        // Iteramos cada uno de los idiomas soportados para crear
        // una lista de PopupMenuItem
        return supportedLocales.entries.map((it) {
          return PopupMenuItem(
            value: it.key,
            child: Text(it.value),
          );
        }).toList();
      },
      // Cada vez que seleccionamos un idioma del meno actualizamos
      // el idioma de la aplicación y del cubit. 
      onSelected: (selectedLocale) {
        context.setLocale(selectedLocale);
        context.read<NewsCubit>().setLocale(selectedLocale);
      },
    );
  }
}

Después lo agregaremos al AppBar en la propiedad de actions:

 // Agregamos el widget _LanguagePopUpMenu al AppBar
 appBar: AppBar(
   title: Text(LocaleKeys.top_headlines.tr()),
   actions: [
      _LanguagePopUpMenu(),
   ],
 ),

Ahora podemos correr la aplicación y tendremos la opción de seleccionar el idioma lo que hace cambiar el texto de los componentes de la interfaz de usuario y además podremos hacer búsquedas en cualquiera de los idiomas soportados.

Con esto finalizamos esta serie de artículos así que recuerda que puedes encontrar el código fuente en GitHub. Y no olvides visitar los otros artículos de esta serie de tutoriales:

Comparte este artículo