Cómo compilar una app de Cupertino con Flutter

Bienvenido al codelab de Cupertino de Flutter.

En este codelab, crearás una app de Cupertino (estilo iOS) con Flutter. El SDK de Flutter incluye dos bibliotecas de widgets con estilo (además de la biblioteca de widgets básica):

  • Los widgets de Material implementan el lenguaje de Material Design para iOS, Android, la Web y las computadoras de escritorio.
  • Los widgets de Cupertino implementan el lenguaje de diseño actual de iOS según las directrices de interfaz humana de Apple.

¿Por qué escribir una app de Cupertino? El lenguaje de Material Design se creó para cualquier plataforma, no solo para Android. Cuando escribes una app de Material en Flutter, tiene el aspecto de Material en todos los dispositivos, incluso en iOS. Si quieres que tu app tenga un aspecto como una app estándar similar a la de iOS, usa la biblioteca de Cupertino.

Técnicamente, puedes ejecutar una app de Cupertino en Android o iOS, pero (debido a problemas de licencia) Cupertino no tendrá las fuentes correctas en Android. Por eso, usa un dispositivo específico para iOS cuando escribas una app de Cupertino.

Implementarás una app de compras del estilo de Cupertino que contiene tres pestañas: una para la lista de productos, otra para la búsqueda de productos y otra para el carrito de compras.

f104a94356854c24.png 6f345bfa17663f9a.png

daf61aa9d823646a.png

Qué aprenderás en este codelab

  • Cómo compilar una app de Flutter con apariencia y estilo de iOS
  • Cómo crear varias pestañas y navegar entre ellas
  • Cómo usar el paquete provider para administrar el estado entre pantallas

¿Qué te gustaría aprender de este codelab?

Desconozco el tema y me gustaría obtener una buena descripción general. Tengo algunos conocimientos sobre este tema, pero me gustaría repasarlos. Estoy buscando un código de ejemplo para usar en mi proyecto. Estoy buscando una explicación sobre un tema específico.

Para completar este lab, necesitas dos software: el SDK de Flutter y un editor. Puedes usar tu editor preferido, como Android Studio o IntelliJ, con los complementos de Flutter y Dart instalados, o Visual Studio Code con las extensiones de Flutter y el código Dart.

Puedes ejecutar este codelab con uno de los siguientes dispositivos:

También necesitarás lo siguiente:

  • Una computadora Mac configurada con Xcode

Crea la app inicial con un elemento CupertinoPageScaffold.

b2f84ff91b0e1396.pngCrea un proyecto de Flutter llamado cupertino_store y migra a la seguridad nula de la siguiente manera.

$ flutter create cupertino_store
$ cd cupertino_store
$ dart migrate --apply-changes

b2f84ff91b0e1396.png Reemplaza el contenido de lib/main.dart. Borra todo el código de lib/main.dart, lo que creará una app de recuento de botones con tema de Material. Reemplázalo con el siguiente código, que inicializa una app Cupertino.

lib/main.dart

import 'package:flutter/cupertino.dart';

import 'app.dart';

void main() {
  return runApp(CupertinoStoreApp());
}

cf1e10b838bf60ee.png Observaciones

  • Importa el paquete de Cupertino. De esta manera, todos los widgets y constantes de Cupertino estarán disponibles para tu app.

b2f84ff91b0e1396.png Crea lib/styles.dart. Agrega un archivo al directorio de lib llamado styles.dart. La clase Styles define el estilo de texto y color para personalizar la app. Este es un ejemplo del archivo, pero puedes obtener el contenido completo en GitHub: lib/styles.dart.

lib/styles.dart

// THIS IS A SAMPLE FILE. Get the full content at the link above.
import 'package:flutter/cupertino.dart';
import 'package:flutter/widgets.dart';

abstract class Styles {
  static const TextStyle productRowItemName = TextStyle(
    color: Color.fromRGBO(0, 0, 0, 0.8),
    fontSize: 18,
    fontStyle: FontStyle.normal,
    fontWeight: FontWeight.normal,
  );

  static const TextStyle productRowTotal = TextStyle(
    color: Color.fromRGBO(0, 0, 0, 0.8),
    fontSize: 18,
    fontStyle: FontStyle.normal,
    fontWeight: FontWeight.bold,
  );

 // ...
// THIS IS A SAMPLE FILE. Get the full content at the link above.

cf1e10b838bf60ee.png Observaciones

  • Podemos centralizar las definiciones de estilo de manera similar a como los desarrolladores web centralizan su lenguaje de marcado de estilos en archivos CSS mediante la agrupación de todas nuestras definiciones en un solo archivo. Esta es la forma más sencilla de reutilizar y redefinir los estilos en toda la app.

b2f84ff91b0e1396.png Crea lib/app.dart y agrega la clase CupertinoStoreApp. Agrega la siguiente clase CupertinoStoreApp a lib/app.dart.

lib/app.dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';

class CupertinoStoreApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // This app is designed only to work vertically, so we limit
    // orientations to portrait up and down.
    SystemChrome.setPreferredOrientations(
        [DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);

    return CupertinoApp(
      theme: const CupertinoThemeData(brightness: Brightness.light),
      home: CupertinoStoreHomePage(),
    );
  }
}

cf1e10b838bf60ee.png Observaciones

  • Importa la biblioteca de servicios. De esta manera, los servicios de la plataforma, como el portapapeles y la configuración de la orientación del dispositivo, están disponibles para tu app.
  • Crea una instancia de CupertinoApp, que proporciona temas, navegación, dirección de texto y otros valores predeterminados, para crear una app que un usuario de iOS espera.
  • Crea una instancia de CupertinoStoreHomePage como página principal.
  • La app está diseñada para funcionar únicamente de forma vertical, por lo que la orientación del dispositivo se limita al modo vertical.

b2f84ff91b0e1396.png Agrega la clase CupertinoStoreHomePage. Agrega la siguiente clase CupertinoStoreHomePage a lib/app.dart para crear el diseño de la página principal.

lib/app.dart

class CupertinoStoreHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return const CupertinoPageScaffold(
      navigationBar: CupertinoNavigationBar(
        middle: Text('Cupertino Store'),
      ),
      child: SizedBox(),
    );
  }
}

cf1e10b838bf60ee.png Observaciones

  • El paquete de Cupertino ofrece dos tipos de estructura principal de la página. CupertinoPageScaffold admite páginas únicas y acepta una barra de navegación con estilo Cupertino, un color de fondo y mantiene el árbol de widgets de la página. Aprenderás sobre el segundo tipo de estructura principal en el siguiente paso.
  • Esta página tiene un título, y el árbol de widgets tiene un solo contenedor vacío.

b2f84ff91b0e1396.png Actualiza el archivo pubspec.yaml. En la parte superior del proyecto, edita el archivo pubspec.yaml. Agrega las bibliotecas que necesitarás y una lista de los elementos de imagen. Aquí puedes ver una muestra del archivo. Busca el contenido completo en GitHub: pubspec.yaml.

pubspec.yaml

# THIS IS A SAMPLE OF THE FILE. Get the full file at the link above.
name: cupertino_store
description: Creating a Store in Cupertino widgets
publish_to: "none" # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1

environment:
  sdk: ">=2.12.0 <3.0.0"

dependencies:
  cupertino_icons: ^1.0.2
  flutter:
    sdk: flutter
  intl: ^0.17.0
  provider: ^5.0.0
  shrine_images: ^2.0.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  pedantic: ^1.11.0

flutter:
  assets:
    - packages/shrine_images/0-0.jpg
# THIS IS A SAMPLE OF THE FILE. Get the full file at the link above.

cf1e10b838bf60ee.png Observaciones

  • Esto incorpora varios paquetes, como shine_images, que contienen productos para propagar productos en la tienda.
  • El paquete provider ofrece una manera sencilla de administrar el estado en todas las pantallas.
  • El paquete intl proporciona instalaciones de internacionalización y localización.
  • El paquete cupertino_icons contiene recursos de íconos para los widgets de Cupertino.

b2f84ff91b0e1396.png Ejecuta la app. Deberías ver la siguiente pantalla blanca que contiene la barra de navegación de Cupertino y un título:

5705e4da178665a5.png

¿Tienes problemas?

Si tu app no se ejecuta correctamente, comprueba que no haya errores ortográficos. De ser necesario, usa el código que aparece en los siguientes vínculos para volver a empezar.

La app final incluye 3 pestañas:

  • Lista de productos
  • Búsqueda de productos
  • Carrito de compras

En este paso, actualizarás la página principal con tres pestañas mediante un elemento CupertinoTabScaffold. También agregarás una fuente de datos que proporcione la lista de artículos para la venta, con fotos y precios.

En el paso anterior, creaste una clase CupertinoStoreHomePage con un elemento CupertinoPageScaffold. Usa esta estructura básica para páginas que no tienen pestañas. La app final tiene tres pestañas, por lo que debes intercambiar CupertinoPageScaffold por CupertinoTabScaffold.

La pestaña Cupertino tiene una estructura básica separada porque en iOS, la pestaña inferior suele ser persistente en rutas anidadas en lugar de estar dentro de las páginas.

b2f84ff91b0e1396.png Actualiza lib/app.dart. Reemplaza la clase CupertinoStoreHomePage por lo siguiente, que configura una estructura de 3 pestañas:

lib/app.dart

class CupertinoStoreHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CupertinoTabScaffold(
      tabBar: CupertinoTabBar(
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(
            icon: Icon(CupertinoIcons.home),
            label: 'Products',
          ),
          BottomNavigationBarItem(
            icon: Icon(CupertinoIcons.search),
            label: 'Search',
          ),
          BottomNavigationBarItem(
            icon: Icon(CupertinoIcons.shopping_cart),
            label: 'Cart',
          ),
        ],
      ),
      tabBuilder: (context, index) {
        late final CupertinoTabView returnValue;
        switch (index) {
          case 0:
            returnValue = CupertinoTabView(builder: (context) {
              return CupertinoPageScaffold(
                child: ProductListTab(),
              );
            });
            break;
          case 1:
            returnValue = CupertinoTabView(builder: (context) {
              return CupertinoPageScaffold(
                child: SearchTab(),
              );
            });
            break;
          case 2:
            returnValue = CupertinoTabView(builder: (context) {
              return CupertinoPageScaffold(
                child: ShoppingCartTab(),
              );
            });
            break;
        }
        return returnValue;
      },
    );
  }
}

cf1e10b838bf60ee.png Observaciones

  • CupertinoTabBar requiere al menos dos elementos. De lo contrario, verás errores en el tiempo de ejecución.
  • El elemento tabBuilder: es el responsable de garantizar que se cree la pestaña especificada. En este caso, llama a un constructor de clase para configurar cada una de las pestañas, y une los tres en CupertinoTabView y CupertinoPageScaffold.

b2f84ff91b0e1396.png Agrega clases de stub para el contenido de las nuevas pestañas. Crea un archivo lib/product_list_tab.dart para la primera pestaña que se compila de forma limpia, pero solo muestra una pantalla blanca. Usa el siguiente contenido:

lib/product_list_tab.dart

import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';

import 'model/app_state_model.dart';

class ProductListTab extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<AppStateModel>(
      builder: (context, model, child) {
        return const CustomScrollView(
          slivers: <Widget>[
            CupertinoSliverNavigationBar(
              largeTitle: Text('Cupertino Store'),
            ),
          ],
        );
      },
    );
  }
}

cf1e10b838bf60ee.png Observaciones

  • La pestaña de la lista de productos es un widget sin estado.
  • El elemento Consumer, del paquete provider, ayuda con la administración del estado. Más adelante, verás información sobre el modelo.
  • Hay 2 variantes de la barra de navegación en iOS. El tipo estático breve común que se observa desde iOS 1 y el tipo de título grande, desplazable de gran tamaño, presentado en iOS 11. En esta página, se implementa el último dentro de un CustomScrollView con un widget CupertinoSliverNavigationBar.

b2f84ff91b0e1396.png Agrega un stub de página de búsqueda. Crea un archivo lib/search_tab.dart que se compile de forma correcta, pero solo muestre una pantalla blanca. Usa el siguiente contenido:

lib/search_tab.dart

import 'package:flutter/cupertino.dart';

class SearchTab extends StatefulWidget {
  @override
  _SearchTabState createState() {
    return _SearchTabState();
  }
}

class _SearchTabState extends State<SearchTab> {
  @override
  Widget build(BuildContext context) {
    return const CustomScrollView(
      slivers: <Widget>[
        CupertinoSliverNavigationBar(
          largeTitle: Text('Search'),
        ),
      ],
    );
  }
}

cf1e10b838bf60ee.png Observaciones

  • La pestaña de búsqueda es un widget con estado porque, a medida que el usuario realiza búsquedas, la lista de resultados cambia.

b2f84ff91b0e1396.png Agrega un código auxiliar para la página del carrito de compras. Crea un archivo lib/shopping_cart_tab.dart que se compile de forma correcta, pero solo muestre una pantalla blanca. Usa el siguiente contenido:

lib/shopping_cart_tab.dart

import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';

import 'model/app_state_model.dart';

class ShoppingCartTab extends StatefulWidget {
  @override
  _ShoppingCartTabState createState() {
    return _ShoppingCartTabState();
  }
}

class _ShoppingCartTabState extends State<ShoppingCartTab> {
  @override
  Widget build(BuildContext context) {
    return Consumer<AppStateModel>(
      builder: (context, model, child) {
        return const CustomScrollView(
          slivers: <Widget>[
            CupertinoSliverNavigationBar(
              largeTitle: Text('Shopping Cart'),
            ),
          ],
        );
      },
    );
  }
}

cf1e10b838bf60ee.png Observaciones

  • La pestaña de carrito de compras es un widget con estado, ya que mantiene la lista de compras y la información del cliente.
  • En esta página, también se usa un elemento CustomScrollView.

b2f84ff91b0e1396.pngActualiza lib/app.dart. Actualiza las instrucciones de importación en lib/app.dart para extraer en los nuevos widgets de la pestaña:

lib/app.dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'product_list_tab.dart';   // NEW
import 'search_tab.dart';         // NEW
import 'shopping_cart_tab.dart';  // NEW

En la segunda parte de este paso, que continúa en la página siguiente, agregarás un código para administrar y compartir el estado en todas las pestañas.

La app tiene datos comunes que se deben compartir en varias pantallas. Por eso, necesitas una manera simple de direccionar los datos a cada uno de los objetos que los necesitan. El paquete provider proporciona una forma sencilla de hacerlo. En provider, debes definir el modelo de datos y, luego, usar ChangeNotifierProvider para proporcionar tu modelo de datos al árbol.

b2f84ff91b0e1396.png Crea las clases de modelo de datos. Crea un directorio model en lib. Agrega un archivo lib/model/product.dart que defina los datos del producto provenientes de la fuente de datos:

lib/model/product.dart

enum Category {
  all,
  accessories,
  clothing,
  home,
}

class Product {
  const Product({
    required this.category,
    required this.id,
    required this.isFeatured,
    required this.name,
    required this.price,
  });

  final Category category;
  final int id;
  final bool isFeatured;
  final String name;
  final int price;

  String get assetName => '$id-0.jpg';
  String get assetPackage => 'shrine_images';

  @override
  String toString() => '$name (id=$id)';
}

cf1e10b838bf60ee.png Observaciones

  • Cada instancia de la clase Product describe un producto para la venta.

La clase ProductsRepository contiene la lista completa de productos a la venta, junto con su precio, texto del título y una categoría. Nuestra app no realizará ninguna acción con la propiedad isFeatured. La clase también incluye un método loadProducts() que muestra todos los productos o los productos de una categoría determinada.

b2f84ff91b0e1396.png Crea el repositorio de productos. Crea un archivo lib/model/products_repository.dart. Este archivo contiene todos los productos para la venta. Cada producto pertenece a una categoría. Aquí se ve una muestra del archivo, pero puedes obtener todo el contenido en GitHub: products_repository.dart.

lib/model/products_repository.dart

// THIS IS A SAMPLE FILE. Get the full content at the link above.

import 'product.dart';

class ProductsRepository {
 static const _allProducts = <Product>[
   Product(
     category: Category.accessories,
     id: 0,
     isFeatured: true,
     name: 'Vagabond sack',
     price: 120,
   ),
   Product(
     category: Category.home,
     id: 9,
     isFeatured: true,
     name: 'Gilt desk trio',
     price: 58,
   ),
   Product(
     category: Category.clothing,
     id: 33,
     isFeatured: true,
     name: 'Cerise scallop tee',
     price: 42,
   ),
   // THIS IS A SAMPLE FILE. Get the full content at the link above.
 ];

 static List<Product> loadProducts(Category category) {
   if (category == Category.all) {
     return _allProducts;
   } else {
     return _allProducts.where((p) => p.category == category).toList();
   }
 }
}

cf1e10b838bf60ee.png Observations

  • En este caso, se crea una base de datos de productos ficticios para facilitar el desarrollo, pero se debería proporcionar a la app como una API. Una forma sencilla de lograrlo que aborda la desconexión parcial de los teléfonos móviles es Cloud Firestore.

Ya estás listo para definir el modelo. Crea un archivo lib/model/app_state_model.dart. En la clase AppStateModel, proporciona métodos para acceder a los datos del modelo. Por ejemplo, agrega un método para acceder al total del carrito de compras, otro para una lista de productos seleccionados, otro para el costo de envío, etc.

b2f84ff91b0e1396.png Crea la clase de modelo. A continuación, se muestra la lista de firmas de métodos proporcionada por esta clase. Obtén el contenido completo en GitHub: lib/model/app_state_model.dart.

lib/model/app_state_model.dart

// THIS IS A SAMPLE FILE ONLY. Get the full content at the link above.

import 'package:flutter/foundation.dart' as foundation;

import 'product.dart';
import 'products_repository.dart';

double _salesTaxRate = 0.06;
double _shippingCostPerItem = 7;

class AppStateModel extends foundation.ChangeNotifier {
 List<Product> _availableProducts = [];
 Category _selectedCategory = Category.all;
 final _productsInCart = <int, int>{};

 Map<int, int> get productsInCart
 int get totalCartQuantity
 Category get selectedCategory
 double get subtotalCost
 double get shippingCost

 double get tax
 double get totalCost
 List<Product> getProducts()
 List<Product> search(String searchTerms)
 void addProductToCart(int productId)
 void removeItemFromCart(int productId)
 Product getProductById(int id)
 void clearCart()
 void loadProducts()
 void setCategory(Category newCategory)
// THIS IS A SAMPLE FILE ONLY. Get the full content at the link above.

cf1e10b838bf60ee.png Observations

  • Nuestro AppStateModel muestra una forma de centralizar el estado de la aplicación y poner el estado a disposición de toda la aplicación. Más adelante, usaremos este estado para mejorar la funcionalidad de Búsqueda y Carrito de compras.

b2f84ff91b0e1396.png Actualiza lib/main.dart. En el método main(), inicializa el modelo. Agrega las líneas marcadas como NEW.

lib/main.dart

import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';             // NEW

import 'app.dart';
import 'model/app_state_model.dart';                 // NEW

void main() {
 return runApp(
   ChangeNotifierProvider<AppStateModel>(            // NEW
     create: (_) => AppStateModel()..loadProducts(), // NEW
     child: CupertinoStoreApp(),                     // NEW
   ),
 );
}

cf1e10b838bf60ee.png Observations

  • Estamos conectando el elemento AppStateModel en la parte superior del árbol de widgets para que esté disponible en toda la app.
  • Estamos utilizando ChangeNotifierProvider del paquete del proveedor, que controla AppStateModel para notificaciones de cambios.

b2f84ff91b0e1396.png Ejecuta la app. Deberías ver la siguiente pantalla blanca con la barra de navegación de Cupertino, un título y un panel con tres íconos con etiquetas que representan las tres pestañas. Puedes alternar entre las pestañas, pero las tres páginas estarán en blanco.

35520995039d98a6.png

¿Tienes problemas?

Si tu app no se ejecuta correctamente, comprueba que no haya errores ortográficos. De ser necesario, usa el código que aparece en los siguientes vínculos para volver a empezar.

En este paso, muestra los productos para la venta en la pestaña de la lista de productos.

b2f84ff91b0e1396.png Agrega lib/product_row_item.dart para mostrar los productos. Crea lib/product_row_item.dart file con el siguiente contenido:

lib/product_row_item.dart

import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';

import 'model/app_state_model.dart';
import 'model/product.dart';
import 'styles.dart';

class ProductRowItem extends StatelessWidget {
  const ProductRowItem({
    required this.product,
    required this.lastItem,
  });

  final Product product;
  final bool lastItem;

  @override
  Widget build(BuildContext context) {
    final row = SafeArea(
      top: false,
      bottom: false,
      minimum: const EdgeInsets.only(
        left: 16,
        top: 8,
        bottom: 8,
        right: 8,
      ),
      child: Row(
        children: <Widget>[
          ClipRRect(
            borderRadius: BorderRadius.circular(4),
            child: Image.asset(
              product.assetName,
              package: product.assetPackage,
              fit: BoxFit.cover,
              width: 76,
              height: 76,
            ),
          ),
          Expanded(
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 12),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.start,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    product.name,
                    style: Styles.productRowItemName,
                  ),
                  const Padding(padding: EdgeInsets.only(top: 8)),
                  Text(
                    '\$${product.price}',
                    style: Styles.productRowItemPrice,
                  )
                ],
              ),
            ),
          ),
          CupertinoButton(
            padding: EdgeInsets.zero,
            onPressed: () {
              final model = Provider.of<AppStateModel>(context, listen: false);
              model.addProductToCart(product.id);
            },
            child: const Icon(
              CupertinoIcons.plus_circled,
              semanticLabel: 'Add',
            ),
          ),
        ],
      ),
    );

    if (lastItem) {
      return row;
    }

    return Column(
      children: <Widget>[
        row,
        Padding(
          padding: const EdgeInsets.only(
            left: 100,
            right: 16,
          ),
          child: Container(
            height: 1,
            color: Styles.productRowDivider,
          ),
        ),
      ],
    );
  }
}

cf1e10b838bf60ee.png Observations

  • CupertinoSliverNavigationBar es cómo obtenemos los títulos que se amplían con estilo iOS 11 en la barra de navegación. Esto es importante para que el usuario de iOS se sienta cómodo en la app.
  • Este archivo presenta mucha complejidad, ya que emulamos la apariencia altamente refinada de las aplicaciones para iOS. El punto destacado de Flutter es que podemos hacer estos cambios en un editor, y verlos en tiempo real gracias a Stateful Hot Reload.

b2f84ff91b0e1396.png En lib/product_list_tab.dart, importa el archivo product_row_item.dart.

lib/product_list_tab.dart

import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';

import 'model/app_state_model.dart';
import 'product_row_item.dart';      // NEW

b2f84ff91b0e1396.png En el método build() para ProductListTab, obtén la lista de productos y la cantidad de productos. Agrega las nuevas líneas que se indican a continuación:

class ProductListTab extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return CupertinoPageScaffold(
     child: Consumer<AppStateModel>(
       builder: (context, child, model) {
         final products = model.getProducts();  // NEW
         return CustomScrollView(
           semanticChildCount: products.length, // NEW
           slivers: <Widget>[
             CupertinoSliverNavigationBar(
               largeTitle: const Text('Cupertino Store'),
             ),
           ],
         );
       },
     ),
   );
 }
}

b2f84ff91b0e1396.png También en el método build(), agrega una nueva parte a la lista de widgets para mantener la lista de productos. Agrega las nuevas líneas que se indican a continuación:

class ProductListTab extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<AppStateModel>(
      builder: (context, model, child) {
        final products = model.getProducts();
        return CustomScrollView(
          semanticChildCount: products.length,
          slivers: <Widget>[
            const CupertinoSliverNavigationBar(
              largeTitle: Text('Cupertino Store'),
            ),
            SliverSafeArea(   // BEGINNING OF NEW CONTENT
              top: false,
              minimum: const EdgeInsets.only(top: 8),
              sliver: SliverList(
                delegate: SliverChildBuilderDelegate(
                  (context, index) {
                    if (index < products.length) {
                      return ProductRowItem(
                        product: products[index],
                        lastItem: index == products.length - 1,
                      );
                    }

                    return null;
                  },
                ),
              ),
            )     // END OF NEW CONTENT
          ],
        );
      },
    );
  }
}

cf1e10b838bf60ee.png Observations

  • En la primera parte, se tiene en cuenta el primer recorte (el CupertinoSliverNavigationBar).
  • La nueva parte y la primera parte son del mismo nivel (no son un elemento principal y uno secundario), por lo que la primera parte no puede comunicar que ya consumió el recorte. Por lo tanto, la segunda parte establece la propiedad top de SliverSafeArea en false para que ignore el recorte.
  • El valor de las propiedades left y right de SliverSafeArea aún está predeterminado en true en caso de que se rote el teléfono, pero aún representa bottom para que la parte pueda desplazarse más allá de la barra de inicio inferior a fin de evitar la obstrucción cuando se la desplaza hasta el final.
  • Esta lógica no se requiere específicamente aquí, ya que la app se restringe únicamente al modo vertical, pero al incluirla, se puede volver a usar este código en apps que admiten una presentación horizontal.

b2f84ff91b0e1396.png Ejecuta la app. En la pestaña de producto, deberías ver una lista de productos con imágenes, precios y un botón con un signo más que agrega el producto al carrito de compras. El botón se implementará más tarde, en el paso de creación del carrito de compras.

f104a94356854c24.png

¿Tienes problemas?

Si tu app no se ejecuta correctamente, comprueba que no haya errores ortográficos. De ser necesario, usa el código que aparece en los siguientes vínculos para volver a empezar.

En este paso, crearás la pestaña de búsqueda y agregarás la capacidad de buscar en los productos.

b2f84ff91b0e1396.png Actualiza las importaciones en lib/search_tab.dart.

Agrega importaciones para las clases que usará la pestaña de búsqueda:

lib/search_tab.dart

import 'package:flutter/cupertino.dart'
import 'package:provider/provider.dart'
import 'model/app_state_model.dart'
import 'product_row_item.dart'
import 'search_bar.dart'
import 'styles.dart'

b2f84ff91b0e1396.png Actualiza el método build() en _SearchTabState.

Inicializa el modelo y reemplaza CustomScrollView por los componentes individuales para realizar búsquedas y listas.

class _SearchTabState extends State<SearchTab> {
// ...

  @override
  Widget build(BuildContext context) {
    final model = Provider.of<AppStateModel>(context);
    final results = model.search(_terms);

    return DecoratedBox(
      decoration: const BoxDecoration(
        color: Styles.scaffoldBackground,
      ),
      child: SafeArea(
        child: Column(
          children: [
            _buildSearchBox(),
            Expanded(
              child: ListView.builder(
                itemBuilder: (context, index) => ProductRowItem(
                  product: results[index],
                  lastItem: index == results.length - 1,
                ),
                itemCount: results.length,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

cf1e10b838bf60ee.png Observations

  • Estamos creando nuevamente una experiencia de búsqueda del estilo de iOS, pero tenemos mucha latitud para personalizar la experiencia del usuario.

b2f84ff91b0e1396.png Agrega variables, funciones y métodos de asistencia a la clase _SearchTabState.

Entre los mencionados, se incluyen initState(), dispose(), _onTextChanged() y _buildSearchBox(), como se muestra a continuación:

class _SearchTabState extends State<SearchTab> {
  late final TextEditingController _controller;
  late final FocusNode _focusNode;
  String _terms = '';

  @override
  void initState() {
    super.initState();
    _controller = TextEditingController()..addListener(_onTextChanged);
    _focusNode = FocusNode();
  }

  @override
  void dispose() {
    _focusNode.dispose();
    _controller.dispose();
    super.dispose();
  }

  void _onTextChanged() {
    setState(() {
      _terms = _controller.text;
    });
  }

  Widget _buildSearchBox() {
    return Padding(
      padding: const EdgeInsets.all(8),
      child: SearchBar(
        controller: _controller,
        focusNode: _focusNode,
      ),
    );
  }    // TO HERE

 @override
 Widget build(BuildContext context) {

cf1e10b838bf60ee.png Observations

  • _SearchTabState es donde se conserva el estado específico de la búsqueda. En esta implementación, almacenamos los términos de búsqueda y ajustaremos el elemento AppStateModel para cumplir la capacidad de búsqueda. En el caso de que implementemos un backend de API, este es un buen lugar para obtener acceso a la red para la Búsqueda.

b2f84ff91b0e1396.png Agrega una clase SearchBar.

Crea un nuevo archivo, lib/search_bar.dart. La clase SearchBar controla la búsqueda real en la lista de productos. Inicia el archivo con el siguiente contenido:

lib/search_bar.dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/widgets.dart';
import 'styles.dart';

class SearchBar extends StatelessWidget {
  const SearchBar({
    required this.controller,
    required this.focusNode,
  });

  final TextEditingController controller;
  final FocusNode focusNode;

  @override
  Widget build(BuildContext context) {
    return DecoratedBox(
      decoration: BoxDecoration(
        color: Styles.searchBackground,
        borderRadius: BorderRadius.circular(10),
      ),
      child: Padding(
        padding: const EdgeInsets.symmetric(
          horizontal: 4,
          vertical: 8,
        ),
        child: Row(
          children: [
            const Icon(
              CupertinoIcons.search,
              color: Styles.searchIconColor,
            ),
            Expanded(
              child: CupertinoTextField(
                controller: controller,
                focusNode: focusNode,
                style: Styles.searchText,
                cursorColor: Styles.searchCursorColor,
                decoration: null,
              ),
            ),
            GestureDetector(
              onTap: controller.clear,
              child: const Icon(
                CupertinoIcons.clear_thick_circled,
                color: Styles.searchIconColor,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

cf1e10b838bf60ee.png Observations

  • Las interfaces de búsqueda en iOS son interesantes porque hay una variación bastante amplia en las implementaciones. Flutter te permite ajustar el diseño y el color de la implementación de manera rápida y fácil.

b2f84ff91b0e1396.png Ejecuta la app. Selecciona la pestaña de búsqueda y, luego, ingresa "camisa" en el campo de texto. Deberías ver una lista de 5 productos que contienen "camisa" en el nombre.

6f345bfa17663f9a.png

¿Tienes problemas?

Si tu app no se ejecuta correctamente, comprueba que no haya errores ortográficos. De ser necesario, usa el código que aparece en los siguientes vínculos para volver a empezar.

En los próximos tres pasos, compilarás la pestaña del carrito de compras. En este primer paso, agregarás campos para capturar la información del cliente.

b2f84ff91b0e1396.png Actualiza el archivo lib/shopping_cart_tab.dart.

Agrega métodos privados para crear los campos Nombre, Correo electrónico y Ubicación. Luego, agrega un método _buildSliverChildBuildDelegate() que cree partes de la interfaz de usuario.

lib/shopping_cart_tab.dart

class _ShoppingCartTabState extends State<ShoppingCartTab> {
  String? Name;    // ADD FROM HERE
  String? email;
  String? location;
  String? pin;
  DateTime dateTime = DateTime.now();

  Widget _buildNameField() {
    return CupertinoTextField(
      prefix: const Icon(
        CupertinoIcons.person_solid,
        color: CupertinoColors.lightBackgroundGray,
        size: 28,
      ),
      padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 12),
      clearButtonMode: OverlayVisibilityMode.editing,
      textCapitalization: TextCapitalization.words,
      autocorrect: false,
      decoration: const BoxDecoration(
        border: Border(
          bottom: BorderSide(
            width: 0,
            color: CupertinoColors.inactiveGray,
          ),
        ),
      ),
      placeholder: 'Name',
      onChanged: (newName) {
        setState(() {
          name = newName;
        });
      },
    );
  }

  Widget _buildEmailField() {
    return const CupertinoTextField(
      prefix: Icon(
        CupertinoIcons.mail_solid,
        color: CupertinoColors.lightBackgroundGray,
        size: 28,
      ),
      padding: EdgeInsets.symmetric(horizontal: 6, vertical: 12),
      clearButtonMode: OverlayVisibilityMode.editing,
      keyboardType: TextInputType.emailAddress,
      autocorrect: false,
      decoration: BoxDecoration(
        border: Border(
          bottom: BorderSide(
            width: 0,
            color: CupertinoColors.inactiveGray,
          ),
        ),
      ),
      placeholder: 'Email',
    );
  }

  Widget _buildLocationField() {
    return const CupertinoTextField(
      prefix: Icon(
        CupertinoIcons.location_solid,
        color: CupertinoColors.lightBackgroundGray,
        size: 28,
      ),
      padding: EdgeInsets.symmetric(horizontal: 6, vertical: 12),
      clearButtonMode: OverlayVisibilityMode.editing,
      textCapitalization: TextCapitalization.words,
      decoration: BoxDecoration(
        border: Border(
          bottom: BorderSide(
            width: 0,
            color: CupertinoColors.inactiveGray,
          ),
        ),
      ),
      placeholder: 'Location',
    );
  }

  SliverChildBuilderDelegate _buildSliverChildBuilderDelegate(
      AppStateModel model) {
    return SliverChildBuilderDelegate(
      (context, index) {
        switch (index) {
          case 0:
            return Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: _buildNameField(),
            );
          case 1:
            return Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: _buildEmailField(),
            );
          case 2:
            return Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: _buildLocationField(),
            );
          default:
          // Do nothing. For now.
        }
        return null;
      },
    );
  }    // TO HERE

cf1e10b838bf60ee.png Observations

  • Una diferencia clave que distingue a Flutter de los entornos de diseño de la interfaz de usuario más tradicional es que tienes el poder de un lenguaje de programación adecuado para introducir abstracciones. Puedes agregar funciones para agrupar la funcionalidad o convertirla en un widget independiente si quieres volver a utilizarla con facilidad. Como programador, tú eliges cómo distribuir la funcionalidad.

b2f84ff91b0e1396.png Actualiza el método build() en la clase _ShoppingCartTabState.

Agrega un elemento SliverSafeArea que llame al método _buildSliverChildBuilderDelegate:

  @override
  Widget build(BuildContext context) {
    return Consumer<AppStateModel>(
      builder: (context, model, child) {
        return CustomScrollView(
          slivers: <Widget>[
            const CupertinoSliverNavigationBar(
              largeTitle: Text('Shopping Cart'),
            ),
            SliverSafeArea(
              top: false,
              minimum: const EdgeInsets.only(top: 4),
              sliver: SliverList(
                delegate: _buildSliverChildBuilderDelegate(model),
              ),
            )
          ],
        );
      },
    );
  }
}

cf1e10b838bf60ee.png Observations

  • Con toda la interfaz de usuario definida en las funciones del compilador, el método de compilación puede ser bastante pequeño.

b2f84ff91b0e1396.png Ejecuta la app. Selecciona la pestaña del carrito de compras. Deberías ver tres campos de texto para recopilar información del cliente:

bcb97c1aff65d3d7.png

¿Tienes problemas?

Si tu app no se ejecuta correctamente, comprueba que no haya errores ortográficos. Si es necesario, usa el código que aparece en el siguiente vínculo para volver a empezar.

En este paso, agrega CupertinoDatePicker al carrito de compras para que el usuario pueda seleccionar la fecha de envío que prefiera.

b2f84ff91b0e1396.png Agrega importaciones y un elemento const a lib/shopping_cart_tab.dart.

Agrega las líneas nuevas, como se muestra a continuación:

lib/shopping_cart_tab.dart

import 'package:flutter/cupertino.dart';
import 'package:intl/intl.dart';            // NEW
import 'package:provider/provider.dart';
import 'model/app_state_model.dart';
import 'styles.dart';                       // NEW

const double _kDateTimePickerHeight = 216;  // NEW

b2f84ff91b0e1396.png Agrega una función _buildDateAndTimePicker() al widget _ShoppingCartTab.

Agrega la función de la siguiente manera:

class _ShoppingCartTabState extends State<ShoppingCartTab> {
  // ...

  Widget _buildDateAndTimePicker(BuildContext context) {
    // NEW FROM HERE
    return Column(
      children: <Widget>[
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: <Widget>[
            Row(
              mainAxisAlignment: MainAxisAlignment.start,
              children: const <Widget>[
                Icon(
                  CupertinoIcons.clock,
                  color: CupertinoColors.lightBackgroundGray,
                  size: 28,
                ),
                SizedBox(width: 6),
                Text(
                  'Delivery time',
                  style: Styles.deliveryTimeLabel,
                ),
              ],
            ),
            Text(
              DateFormat.yMMMd().add_jm().format(dateTime),
              style: Styles.deliveryTime,
            ),
          ],
        ),
        Container(
          height: _kDateTimePickerHeight,
          child: CupertinoDatePicker(
            mode: CupertinoDatePickerMode.dateAndTime,
            initialDateTime: dateTime,
            onDateTimeChanged: (newDateTime) {
              setState(() {
                dateTime = newDateTime;
              });
            },
          ),
        ),
      ],
    );
  }    // TO HERE

SliverChildBuilderDelegate _buildSliverChildBuilderDelegate(
   AppStateModel model) {
  // ...

cf1e10b838bf60ee.png Observations

  • Agregar un elemento CupertinoDatePicker es una tarea rápida y brinda a los usuarios de iOS una forma intuitiva de ingresar fechas y horas.

b2f84ff91b0e1396.png Agrega una llamada a fin de compilar la IU de fecha y hora para la función _buildSliverChildBuilderDelegate. Agrega el nuevo código, como se muestra a continuación:

  SliverChildBuilderDelegate _buildSliverChildBuilderDelegate(
      AppStateModel model) {
    return SliverChildBuilderDelegate(
      (context, index) {
        switch (index) {
          case 0:
            return Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: _buildNameField(),
            );
          case 1:
            return Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: _buildEmailField(),
            );
          case 2:
            return Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: _buildLocationField(),
            );
          case 3:                // ADD FROM HERE
            return Padding(
              padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
              child: _buildDateAndTimePicker(context),
            );                   // TO HERE
          default:
          // Do nothing. For now.
        }
        return null;
      },
    );
  }

b2f84ff91b0e1396.png Ejecuta la app. Selecciona la pestaña del carrito de compras. Deberías ver un selector de fecha de estilo iOS debajo de los campos de texto para recopilar la información del cliente:

ecd9ef206f1e86c7.png

¿Tienes problemas?

Si tu app no se ejecuta correctamente, comprueba que no haya errores ortográficos. Si es necesario, usa el código que aparece en el siguiente vínculo para volver a empezar.

En este paso, agrega los artículos seleccionados al carrito de compras para completar la app.

b2f84ff91b0e1396.png Importa el paquete de productos en shopping_cart_tab.dart.

lib/shopping_cart_tab.dart

import 'package:flutter/cupertino.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'model/app_state_model.dart';
import 'model/product.dart';              // NEW
import 'styles.dart';

b2f84ff91b0e1396.png Agrega un formato de moneda a la clase _ShoppingCartTabState.

Agrega la línea marcada como NEW:

class _ShoppingCartTabState extends State<ShoppingCartTab> {
  String? name;
  String? email;
  String? location;
  String? pin;
  DateTime dateTime = DateTime.now();
  final _currencyFormat = NumberFormat.currency(symbol: '\$'); // NEW

b2f84ff91b0e1396.png Agrega un índice de producto a la función _buildSliverChildBuilderDelegate.

Agrega la línea marcada como NEW:

SliverChildBuilderDelegate _buildSliverChildBuilderDelegate(
   AppStateModel model) {
 return SliverChildBuilderDelegate(
   (context, index) {
     final productIndex = index - 4;    // NEW
     switch (index) {
  // ...

b2f84ff91b0e1396.png En la misma función, muestra los elementos que deseas comprar.

Agrega el código a la sección default: de la declaración del botón, de la siguiente manera:

switch (index) {
 case 0:
   return Padding(
     padding: const EdgeInsets.symmetric(horizontal: 16),
     child: _buildNameField(),
   );
 case 1:
   return Padding(
     padding: const EdgeInsets.symmetric(horizontal: 16),
     child: _buildEmailField(),
   );
 case 2:
   return Padding(
     padding: const EdgeInsets.symmetric(horizontal: 16),
     child: _buildLocationField(),
   );
 case 3:
   return Padding(
     padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
     child: _buildDateAndTimePicker(context),
   );
 default:                      // NEW FROM HERE
   if (model.productsInCart.length > productIndex) {
     return ShoppingCartItem(
       index: index,
       product: model.getProductById(
           model.productsInCart.keys.toList()[productIndex]),
       quantity: model.productsInCart.values.toList()[productIndex],
       lastItem: productIndex == model.productsInCart.length - 1,
       formatter: _currencyFormat,
     );
   } else if (model.productsInCart.keys.length == productIndex &&
       model.productsInCart.isNotEmpty) {
     return Padding(
       padding: const EdgeInsets.symmetric(horizontal: 20),
       child: Row(
         mainAxisAlignment: MainAxisAlignment.end,
         children: <Widget>[
           Column(
             crossAxisAlignment: CrossAxisAlignment.end,
             children: <Widget>[
               Text(
                 'Shipping '
                 '${_currencyFormat.format(model.shippingCost)}',
                  style: Styles.productRowItemPrice,
               ),
               const SizedBox(height: 6),
               Text(
                 'Tax ${_currencyFormat.format(model.tax)}',
                 style: Styles.productRowItemPrice,
                ),
                const SizedBox(height: 6),
                Text(
                  'Total ${_currencyFormat.format(model.totalCost)}',
                  style: Styles.productRowTotal,
                ),
              ],
            )
          ],
        ),
      );
    }
}                       // TO HERE

b2f84ff91b0e1396.png En la parte inferior del archivo, agrega una nueva clase ShoppingCartItem:

class ShoppingCartItem extends StatelessWidget {
  const ShoppingCartItem({
    required this.index,
    required this.product,
    required this.lastItem,
    required this.quantity,
    required this.formatter,
  });

  final Product product;
  final int index;
  final bool lastItem;
  final int quantity;
  final NumberFormat formatter;

  @override
  Widget build(BuildContext context) {
    final row = SafeArea(
      top: false,
      bottom: false,
      child: Padding(
        padding: const EdgeInsets.only(
          left: 16,
          top: 8,
          bottom: 8,
          right: 8,
        ),
        child: Row(
          children: <Widget>[
            ClipRRect(
              borderRadius: BorderRadius.circular(4),
              child: Image.asset(
                product.assetName,
                package: product.assetPackage,
                fit: BoxFit.cover,
                width: 40,
                height: 40,
              ),
            ),
            Expanded(
              child: Padding(
                padding: const EdgeInsets.symmetric(horizontal: 12),
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.start,
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: <Widget>[
                        Text(
                          product.name,
                          style: Styles.productRowItemName,
                        ),
                        Text(
                          '${formatter.format(quantity * product.price)}',
                          style: Styles.productRowItemName,
                        ),
                      ],
                    ),
                    const SizedBox(
                      height: 4,
                    ),
                    Text(
                      '${quantity > 1 ? '$quantity x ' : ''}'
                      '${formatter.format(product.price)}',
                      style: Styles.productRowItemPrice,
                    )
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );

    return row;
  }
}

b2f84ff91b0e1396.png Ejecuta la app. En la pestaña de productos, selecciona algunos artículos para comprar con el botón del signo más que se encuentra a la derecha de cada artículo. Selecciona la pestaña del carrito de compras. Deberías ver los artículos enumerados en el carrito de compras debajo del selector de fecha:

28201e6fa0dc3102.png

¿Tienes problemas?

Si tu app no se ejecuta correctamente, comprueba que no haya errores ortográficos. Si es necesario, usa el código que aparece en el siguiente vínculo para volver a empezar.

¡Felicitaciones!

Completaste el codelab y creaste una app de Flutter con aspecto de Cupertino. También usaste el paquete provider para administrar el estado de la app en varias pantallas. Cuando tengas tiempo, puedes consultar la documentación sobre la administración de estados para obtener más información.

Pasos adicionales

En este codelab, compilaste un frontend para una experiencia de compra, pero el paso siguiente para hacerlo real es crear un backend que controle las cuentas de usuario, los productos, los carritos de compras, etc. Existen varias formas de lograr este objetivo:

Más información

Puedes obtener más información en los siguientes vínculos: