Développer une application Cupertino avec Flutter

Bienvenue dans l'atelier de programmation Flutter Cupertino !

Vous allez créer ici une application Cupertino (style iOS) à l'aide de Flutter. Le SDK Flutter est fourni avec deux bibliothèques de widgets stylisés (en plus de la bibliothèque de widgets de base) :

  • Les widgets Material Design implémentent le langage de conception du même nom pour iOS, Android, le Web et les ordinateurs de bureau.
  • Les widgets Cupertino implémentent le langage de conception actuel pour iOS en fonction des directives de l'interface humaine d'Apple.

Pourquoi créer une application Cupertino ? Le langage Material Design a été créé pour n'importe quelle plate-forme, et pas seulement pour Android. Lorsque vous développez une application Material dans Flutter, elle a l'apparence et la convivialité de Material sur tous les appareils, même sur iOS. Si vous voulez qu'elle ressemble à une application de style iOS standard, vous devez alors utiliser la bibliothèque Cupertino.

Techniquement, vous pouvez exécuter une application Cupertino sur Android ou iOS, mais (en raison de problèmes de licence) Cupertino n'aura pas les polices adéquates sur Android. C'est pourquoi vous devez utiliser un appareil spécifique à iOS lors de la création d'une application Cupertino.

Vous allez implémenter une application d'achat en ligne de style Cupertino composée de trois onglets : un pour la liste de produits, un autre pour la recherche de produits et le dernier pour le panier.

f104a94356854c24.png 6f345bfa17663f9a.png

daf61aa9d823646a.png

Ce que vous allez apprendre

  • Créer une application Flutter ayant l'apparence et la convivialité de iOS
  • Créer plusieurs onglets et naviguer entre eux
  • Utiliser le package provider pour gérer l'état de l'application sur tous les écrans

Qu'attendez-vous de cet atelier de programmation ?

Je suis novice en la matière et je voudrais avoir un bon aperçu. Je connais un peu le sujet, mais j'aimerais revoir certains points. Je recherche un exemple de code à utiliser dans mon projet. Je cherche des explications sur un point spécifique.

Pour cet atelier, vous avez besoin de deux logiciels : le SDK Flutter et un éditeur. Vous pouvez utiliser l'éditeur de votre choix, par exemple Android Studio, IntelliJ avec les plug-ins Flutter et Dart, ou Visual Studio Code avec les extensions Dart Code et Flutter.

Vous pouvez exécuter cet atelier de programmation sur l'un des appareils suivants :

Appareil supplémentaire requis :

  • Un ordinateur Mac configuré avec Xcode

Créez l'application initiale à l'aide d'un CupertinoPageScaffold.

b2f84ff91b0e1396.png Créez un projet Flutter intitulé cupertino_store et effectuez une migration vers Null Safety comme suit.

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

b2f84ff91b0e1396.png Remplacez le contenu du fichier lib/main.dart : supprimez tout le code du fichier lib/main.dart (ce qui crée une application de comptabilisation à boutons de type Material) et remplacez-le par le code suivant (qui initialise une application Cupertino).

lib/main.dart

import 'package:flutter/cupertino.dart';

import 'app.dart';

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

cf1e10b838bf60ee.png Observations

  • En important le package Cupertino, vous disposez alors de l'ensemble des widgets et constantes de Cupertino pour votre application.

b2f84ff91b0e1396.png Créez le fichier lib/styles.dart : ajoutez un fichier nommé styles.dart au répertoire lib. La classe Styles définit le style du texte et des couleurs pour personnaliser l'application. Voici un exemple de ce fichier, mais vous pouvez récupérer le contenu complet sur 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 Observations

  • Vous pouvez centraliser les définitions de styles d'une manière semblable à celle dont les développeurs Web centralisent leur balisage de styles dans des fichiers CSS en regroupant toutes vos définitions dans un seul fichier. Cela permet de réutiliser et de redéfinir facilement des styles dans toute l'application.

b2f84ff91b0e1396.png Créez le fichier lib/app.dart et ajoutez-y la classe CupertinoStoreApp : ajoutez la classe CupertinoStoreApp ci-après dans le fichier 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 Observations

  • En important la bibliothèque de services, vous disposez ainsi des services de la plate-forme, comme le presse-papiers et la définition de l'orientation de l'appareil, pour votre application.
  • Une instance de CupertinoApp est créée (ce qui fournit le thème, la navigation, l'orientation du texte et d'autres paramètres par défaut requis pour créer une application comme l'envisage un utilisateur iOS).
  • Une instance de CupertinoStoreHomePage est créée comme page d'accueil.
  • L'application a été conçue pour fonctionner uniquement verticalement (l'orientation de l'appareil est donc limitée au mode Portrait).

b2f84ff91b0e1396.png Ajoutez la classe CupertinoStoreHomePage : pour créer la mise en page de la page d'accueil, ajoutez la classe CupertinoStoreHomePage ci-dessous dans le fichier lib/app.dart.

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 Observations

  • Le package Cupertino propose deux types d'échafaudages de page. CupertinoPageScaffold accepte les pages individuelles, une barre de navigation style Cupertino et une couleur d'arrière-plan. Il contient également l'arborescence des widgets de la page. Vous en saurez plus sur le second type d'échafaudage à la prochaine étape.
  • Cette page possède un titre, et l'arborescence de widgets contient un seul conteneur vide.

b2f84ff91b0e1396.png Modifiez le fichier pubspec.yaml : en haut du projet, ajoutez au fichier pubspec.yaml les bibliothèques dont vous aurez besoin ainsi qu'une liste des éléments image. Voici un exemple de ce fichier, mais vous pouvez récupérer le contenu complet sur 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 Observations

  • Plusieurs packages (y compris shrine_images) contenant des produits pour alimenter le magasin sont ainsi extraits.
  • Le package provider permet de gérer facilement l'état sur tous les écrans.
  • Le package intl fournit des fonctionnalités de localisation et d'internationalisation.
  • Le package cupertino_icons contient des éléments d'icône pour les widgets Cupertino.

b2f84ff91b0e1396.png Exécutez l'application. Vous devriez obtenir l'écran blanc ci-dessous, avec la barre de navigation Cupertino et un titre :

5705e4da178665a5.png

Des problèmes ?

Si votre application ne s'exécute pas correctement, vérifiez que vous n'avez pas fait de faute de frappe. Si nécessaire, utilisez le code disponible via les liens ci-dessous pour résoudre le problème.

L'application finale comporte trois onglets :

  • Produits
  • Rechercher
  • Panier

Au cours de cette étape, vous allez modifier la page d'accueil contenant les trois onglets à l'aide d'un CupertinoTabScaffold. Vous allez également ajouter une source de données qui fournit la liste des articles à vendre, ainsi que des photos et les prix.

À l'étape précédente, vous avez créé une classe CupertinoStoreHomePage à l'aide d'un CupertinoPageScaffold. Utilisez cet échafaudage pour les pages qui n'ont pas d'onglets. L'application finale comportant trois onglets, remplacez CupertinoPageScaffold par CupertinoTabScaffold.

L'onglet Cupertino possède un échafaudage distinct, car sur iOS, l'onglet du bas est généralement fixe, au-dessus des routes imbriquées plutôt que dans les pages.

b2f84ff91b0e1396.png Modifiez le fichier lib/app.dart. Remplacez la classe CupertinoStoreHomePage par ce qui suit pour avoir un échafaudage à trois onglets :

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 Observations

  • CupertinoTabBar nécessite au moins deux éléments sous peine de générer des erreurs lors de l'exécution.
  • Le tabBuilder: est chargé de s'assurer que l'onglet spécifié est compilé. Dans ce cas, il appelle un constructeur de classe pour configurer chaque onglet respectif, en encapsulant les trois onglets dans CupertinoTabView et CupertinoPageScaffold.

b2f84ff91b0e1396.png Ajoutez des classes bouchons pour le contenu des nouveaux onglets. Créez un fichier lib/product_list_tab.dart pour le premier onglet qui se compile correctement, mais qui n'affiche qu'un écran blanc. Utilisez le contenu suivant :

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 Observations

  • L'onglet "Produits" est un widget sans état.
  • Le Consumer, du package provider, facilite la gestion de l'état (vous en saurez plus sur le modèle par la suite).
  • Il existe deux variantes de la barre de navigation sur iOS : la petite (courante et statique disponible depuis iOS 1) et la grande qui défile (introduite dans iOS 11). Cette page implémente la grande dans une CustomScrollView avec un widget CupertinoSliverNavigationBar.

b2f84ff91b0e1396.png Ajoutez un bouchon pour la page de recherche. Créez un fichier lib/search_tab.dart qui se compile correctement, mais qui n'affiche qu'un écran blanc. Utilisez le contenu suivant :

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 Observations

  • L'onglet "Rechercher" est un widget avec état, car lorsque l'utilisateur effectue une recherche, la liste des résultats change.

b2f84ff91b0e1396.png Ajoutez un bouchon pour la page du panier. Créez un fichier lib/shopping_cart_tab.dart qui se compile correctement, mais qui n'affiche qu'un écran blanc. Utilisez le contenu suivant :

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 Observations

  • L'onglet "Panier" est un widget avec état, car il conserve la liste des achats et les informations sur le client.
  • Cette page utilise également une CustomScrollView.

b2f84ff91b0e1396.png Modifiez le fichier lib/app.dart : changez les instructions d'importation dans le fichier lib/app.dart pour extraire les widgets des nouveaux onglets.

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

Dans la deuxième partie de cette étape, qui se poursuit sur la page suivante, vous allez ajouter du code pour gérer et partager l'état sur tous les onglets.

L'application contient des données communes qui doivent être partagées sur plusieurs écrans. Vous devez donc trouver un moyen simple de transmettre les données à chacun des objets qui en ont besoin (ce que permet justement le package provider). Dans provider, vous allez définir le modèle de données, puis utiliser ChangeNotifierProvider pour fournir ce modèle dans l'arborescence.

b2f84ff91b0e1396.png Créez les classes du modèle de données. Créez un répertoire model sous lib. Ajoutez un fichier lib/model/product.dart qui définit les données produit provenant de la source de données :

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 Observations

  • Chaque instance de la classe "Product" décrit un produit à vendre.

La classe ProductsRepository contient la liste complète des produits à vendre, ainsi que le prix, le titre et la catégorie de chacun. L'application ne fera rien avec la propriété isFeatured. Cette classe inclut également une méthode loadProducts() qui renvoie tous les produits ou tous ceux d'une catégorie donnée.

b2f84ff91b0e1396.png Créez le dépôt de produits. Créez un fichier lib/model/products_repository.dart qui contient tous les produits à vendre. Chaque produit appartient à une catégorie. Voici un exemple de ce fichier, mais vous pouvez récupérer le contenu complet sur 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

  • Dans le cas présent, vous créez une base de données de produits fictive pour faciliter le développement, mais qui doit être transmise à l'application sous la forme d'une API. Pour cela, un moyen simple consiste à utiliser Cloud Firestore, qui traite la réalité partiellement déconnectée des téléphones mobiles.

Vous êtes maintenant prêt à définir le modèle. Créez un fichier lib/model/app_state_model.dart. Dans la classe AppStateModel, fournissez des méthodes permettant d'accéder aux données du modèle. Par exemple, ajoutez une méthode pour accéder au total du panier, une autre pour obtenir la liste des produits sélectionnés à acheter, une autre pour les frais de livraison, etc.

b2f84ff91b0e1396.png Créez la classe de modèle. Voici la liste des signatures de méthode fournies par cette classe. Récupérez le contenu complet sur 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

  • L'AppStateModel montre comment centraliser l'état de l'application et le rendre disponible dans toute l'application. Lors des prochaines étapes, nous allons utiliser cet état pour améliorer les fonctionnalités associées à la recherche et au panier.

b2f84ff91b0e1396.png Modifiez le fichier lib/main.dart. Dans la méthode main(), initialisez le modèle. Ajoutez les lignes marquées "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

  • L'AppStateModel est placé en haut de l'arborescence des widgets pour être disponible dans toute l'application.
  • Le ChangeNotifierProvider, issu du package provider, surveille AppStateModel pour les notifications de modification.

b2f84ff91b0e1396.png Exécutez l'application. Vous devriez obtenir l'écran blanc ci-dessous, avec la barre de navigation Cupertino, un titre et un panneau à trois icônes libellées pour chacun des trois onglets. Vous pouvez passer d'un onglet à l'autre, mais les trois pages sont vides pour l'instant.

35520995039d98a6.png

Des problèmes ?

Si votre application ne s'exécute pas correctement, vérifiez que vous n'avez pas fait de faute de frappe. Si nécessaire, utilisez le code disponible via les liens ci-dessous pour résoudre le problème.

À cette étape, affichez les produits à vendre dans l'onglet "Produits".

b2f84ff91b0e1396.png Ajoutez lib/product_row_item.dart pour afficher les produits. Créez le fichier lib/product_row_item.dart file avec le contenu suivant :

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 permet d'afficher des titres style iOS 11 dans la barre de navigation. C'est important pour que l'utilisateur iOS retrouve ses repères dans l'application.
  • Ce fichier est très complexe, car nous émulons l'apparence et la convivialité très affinées des applications iOS. La force de Flutter réside dans le fait que vous pouvez apporter ces modifications dans un éditeur et voir leur application quasiment en temps réel grâce à la fonctionnalité d'actualisation à chaud avec état.

b2f84ff91b0e1396.png Dans lib/product_list_tab.dart, importez le fichier 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 Dans la méthode build() pour ProductListTab, récupérez la liste et le nombre de produits. Ajoutez les lignes ci-dessous :

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 Toujours dans la méthode build(), ajoutez un sliver à la liste des widgets Sliver pour contenir la liste de produits. Ajoutez les lignes ci-dessous :

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

  • L'encoche est prise en compte par le premier sliver (CupertinoSliverNavigationBar).
  • Le nouveau sliver et le premier sliver sont frère et sœur (et non parent et enfant). Le premier sliver n'a donc aucun moyen d'indiquer qu'il a déjà utilisé l'encoche. C'est pourquoi le second sliver définit la propriété top de SliverSafeArea sur false afin d'ignorer l'encoche.
  • Les propriétés left et right de SliverSafeArea sont toujours définies par défaut sur true en cas de rotation du téléphone, et la propriété bottom est toujours prise en compte afin que le sliver puisse défiler au-delà de la barre d'accueil inférieure pour éviter toute obstruction lorsqu'il défile jusqu'à la fin.
  • Cette logique n'est pas spécifiquement requise ici, car l'application est limitée au mode Portrait. Toutefois, le fait de l'inclure signifie que ce code peut être réutilisé de façon sûre dans les applications ayant une présentation horizontale.

b2f84ff91b0e1396.png Exécutez l'application. Dans l'onglet "Produits", vous devriez voir chaque produit accompagné d'une image, de son prix et d'un bouton pour l'ajouter au panier. Ce bouton sera intégré plus tard, lorsque vous créerez le panier.

f104a94356854c24.png

Des problèmes ?

Si votre application ne s'exécute pas correctement, vérifiez que vous n'avez pas fait de faute de frappe. Si nécessaire, utilisez le code disponible via les liens ci-dessous pour résoudre le problème.

Au cours de cette étape, vous allez créer l'onglet "Rechercher" et ajouter la possibilité d'effectuer des recherches dans les produits.

b2f84ff91b0e1396.png Modifiez les importations dans lib/search_tab.dart.

Ajoutez des importations pour les classes que l'onglet "Rechercher" va utiliser :

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 Modifiez la méthode build() dans _SearchTabState.

Initialisez le modèle et remplacez la CustomScrollView par des composants individuels pour la recherche et l'établissement de la liste.

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

  • Vous recréez ici une expérience de recherche style iOS, mais avec une grande latitude pour personnaliser l'expérience utilisateur.

b2f84ff91b0e1396.png Ajoutez des variables, fonctions et méthodes compatibles à la classe _SearchTabState.

Cela inclut initState(), dispose(), _onTextChanged() et _buildSearchBox(), comme indiqué ci-dessous :

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 est l'endroit où l'état spécifique à la recherche est conservé. Dans cette implémentation, vous stockez les termes de recherche et accédez au AppStateModel pour remplir la fonctionnalité de recherche. Dans le cas où vous implémentez un backend d'API, c'est ici un bon endroit pour accéder au réseau pour la recherche.

b2f84ff91b0e1396.png Ajoutez une classe SearchBar.

Créez un fichier lib/search_bar.dart. La classe SearchBar gère la recherche effectuée dans la liste de produits. Complétez le fichier avec le contenu suivant :

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

  • Les interfaces de recherche sur iOS présentent un intérêt dans la mesure où les implémentations varient raisonnablement. Flutter vous permet d'ajuster rapidement et facilement la mise en page et la couleur de l'implémentation.

b2f84ff91b0e1396.png Exécutez l'application. Sélectionnez l'onglet Rechercher, puis saisissez "chemise" dans le champ de texte. Vous devriez voir une liste de cinq produits dont le nom contient le mot "chemise".

6f345bfa17663f9a.png

Des problèmes ?

Si votre application ne s'exécute pas correctement, vérifiez que vous n'avez pas fait de faute de frappe. Si nécessaire, utilisez le code disponible via les liens ci-dessous pour résoudre le problème.

Au cours des trois prochaines étapes, vous allez créer l'onglet "Panier". Pour commencer, vous allez ajouter des champs permettant de recueillir des informations sur les clients.

b2f84ff91b0e1396.png Modifiez le fichier lib/shopping_cart_tab.dart.

Ajoutez des méthodes privées pour créer les champs "Nom", "Adresse e-mail" et "Lieu". Ajoutez ensuite une méthode _buildSliverChildBuildDelegate() pour créer des parties de l'interface utilisateur.

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

  • L'une des principales différences entre Flutter et les environnements de conception d'interface utilisateur plus classiques est que vous disposez de toute la puissance d'un langage de programmation approprié pour introduire des abstractions. Vous pouvez ajouter des fonctions à un groupe ou les transformer en widget autonome si vous voulez les réutiliser facilement. En tant que programmeur, le choix de la disposition des fonctionnalités vous appartient.

b2f84ff91b0e1396.png Modifiez la méthode build() dans la classe _ShoppingCartTabState.

Ajoutez une SliverSafeArea qui appelle la méthode _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

  • Avec toute l'interface utilisateur définie dans les fonctions de compilateur, la méthode de compilation peut être assez réduite.

b2f84ff91b0e1396.png Exécutez l'application. Sélectionnez l'onglet Panier. Vous devriez voir trois champs de texte permettant de recueillir des informations sur le client :

bcb97c1aff65d3d7.png

Des problèmes ?

Si votre application ne s'exécute pas correctement, vérifiez que vous n'avez pas fait de faute de frappe. Si nécessaire, utilisez le code disponible via le lien ci-dessous pour résoudre le problème.

À cette étape, ajoutez un CupertinoDatePicker au panier pour permettre à l'utilisateur de sélectionner la date de livraison de son choix.

b2f84ff91b0e1396.png Ajoutez des importations et une const dans le fichier lib/shopping_cart_tab.dart.

Ajoutez les nouvelles lignes, comme indiqué ci-dessous :

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 Ajoutez une fonction _buildDateAndTimePicker() au widget _ShoppingCartTab.

Ajoutez la fonction comme suit :

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

  • L'ajout d'un CupertinoDatePicker est rapide et permet à l'utilisateur iOS de saisir facilement la date et l'heure.

b2f84ff91b0e1396.png Ajoutez un appel pour créer l'interface utilisateur de date et heure, à la fonction _buildSliverChildBuilderDelegate. Ajoutez le nouveau code, comme indiqué ci-dessous :

  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 Exécutez l'application. Sélectionnez l'onglet Panier. Vous devriez voir un sélecteur de date style iOS sous les champs de texte servant à recueillir des informations sur le client :

ecd9ef206f1e86c7.png

Des problèmes ?

Si votre application ne s'exécute pas correctement, vérifiez que vous n'avez pas fait de faute de frappe. Si nécessaire, utilisez le code disponible via le lien ci-dessous pour résoudre le problème.

À cette étape, ajoutez les articles sélectionnés au panier pour terminer l'application.

b2f84ff91b0e1396.png Importez le package de produits dans 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 Ajoutez un format de devise à la classe _ShoppingCartTabState.

Ajoutez la ligne marquée "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 Ajoutez un index de produits à la fonction _buildSliverChildBuilderDelegate.

Ajoutez la ligne marquée "NEW" :

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

b2f84ff91b0e1396.png Dans la même fonction, affichez les articles à acheter.

Ajoutez le code à la section default: de l'instruction switch, comme suit :

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 À la fin du fichier, ajoutez une nouvelle classe 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 Exécutez l'application. Dans l'onglet "Produits", sélectionnez quelques articles à acheter à l'aide du bouton "+" situé à droite de chaque article. Sélectionnez l'onglet Panier. Vous devriez voir les articles ajoutés au panier sous le sélecteur de date :

28201e6fa0dc3102.png

Des problèmes ?

Si votre application ne s'exécute pas correctement, vérifiez que vous n'avez pas fait de faute de frappe. Si nécessaire, utilisez le code disponible via le lien ci-dessous pour résoudre le problème.

Félicitations !

Vous avez terminé l'atelier de programmation et créé une application Flutter ayant l'apparence et la convivialité de Cupertino. Vous avez également utilisé le package provider pour gérer l'état de l'application sur tous les écrans. Quand vous aurez un instant, vous pourrez consulter la documentation sur la gestion de l'état pour en savoir plus à ce sujet.

Autres étapes

Lors de cet atelier de programmation, vous avez créé un frontal permettant aux utilisateurs d'acheter des produits. Pour le concrétiser, il vous faut maintenant créer un backend qui gère les comptes utilisateur, les produits, les paniers, etc. Pour ce faire, plusieurs méthodes s'offrent à vous :

En savoir plus

D'autres informations sont également disponibles via les liens suivants :